@agentprojectcontext/apx 1.50.1 → 1.51.1
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/package.json +1 -1
- package/src/core/agent/prompt-builder.js +15 -4
- package/src/core/voice/emotions.js +108 -0
- package/src/core/voice/engines/index.js +39 -9
- package/src/core/voice/engines/openai.js +55 -19
- package/src/core/voice/tts.js +9 -2
- package/src/host/daemon/api/voice.js +9 -1
- package/src/interfaces/web/dist/assets/index-C1tm8nvc.js +651 -0
- package/src/interfaces/web/dist/assets/index-C1tm8nvc.js.map +1 -0
- package/src/interfaces/web/dist/assets/{index-C53eJujd.css → index-L1pXYFUg.css} +1 -1
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +77 -77
- package/src/interfaces/web/src/components/UiSelect.tsx +2 -1
- package/src/interfaces/web/src/components/voice/VoiceProviderList.tsx +101 -147
- package/src/interfaces/web/src/components/voice/VoiceProviderModal.tsx +137 -6
- package/src/interfaces/web/src/components/voice/VoiceTestCard.tsx +17 -24
- package/src/interfaces/web/src/i18n/en.ts +34 -2
- package/src/interfaces/web/src/i18n/es.ts +34 -2
- package/src/interfaces/web/src/lib/api/voice.ts +36 -3
- package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +29 -37
- package/src/interfaces/web/dist/assets/index-rx9XIp5S.js +0 -656
- package/src/interfaces/web/dist/assets/index-rx9XIp5S.js.map +0 -1
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@ import { agentsMdFile } from "../apc/paths.js";
|
|
|
21
21
|
import { readSelfMemoryForPrompt } from "./self-memory.js";
|
|
22
22
|
import { buildSkillsHintBlock } from "./skills/catalog.js";
|
|
23
23
|
import { CHANNELS } from "#core/constants/channels.js";
|
|
24
|
+
import { activeEmotionGuide, buildEmotionGuide } from "../voice/emotions.js";
|
|
24
25
|
|
|
25
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
const PROMPTS_DIR = path.join(__dirname, "prompts");
|
|
@@ -99,13 +100,16 @@ export function buildChannelContextBlock(channel, meta = {}) {
|
|
|
99
100
|
return renderPromptTemplate(loadPrompt(rel), meta);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
export function buildVoiceModeBlock(active) {
|
|
103
|
+
export function buildVoiceModeBlock(active, emotionGuide = "") {
|
|
103
104
|
if (!active) return "";
|
|
105
|
+
let base = "";
|
|
104
106
|
try {
|
|
105
|
-
|
|
107
|
+
base = loadPrompt(VOICE_MODE_FILE);
|
|
106
108
|
} catch {
|
|
107
|
-
|
|
109
|
+
base = "";
|
|
108
110
|
}
|
|
111
|
+
if (!emotionGuide) return base;
|
|
112
|
+
return base ? `${base}\n\n${emotionGuide}` : emotionGuide;
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
// Pick the right segmenting discipline for the channel (and whether voice
|
|
@@ -287,7 +291,14 @@ export function buildSuperAgentSystem({
|
|
|
287
291
|
|
|
288
292
|
const channelBlock = buildChannelContextBlock(channel, channelMeta);
|
|
289
293
|
const extraContext = [channelBlock, contextNote].filter(Boolean).join("\n\n");
|
|
290
|
-
|
|
294
|
+
// In voice mode, if the engine that will speak supports inline emotion tags
|
|
295
|
+
// (a per-engine config toggle), teach the agent the syntax. channelMeta
|
|
296
|
+
// .ttsProvider optionally forces which engine's capability to honor.
|
|
297
|
+
const emotion = voice ? activeEmotionGuide(globalConfig, channelMeta?.ttsProvider) : null;
|
|
298
|
+
const voiceBlock = buildVoiceModeBlock(
|
|
299
|
+
voice,
|
|
300
|
+
emotion ? buildEmotionGuide(emotion.tags) : ""
|
|
301
|
+
);
|
|
291
302
|
const segmentDiscipline = buildSegmentDiscipline({ channel: channelLow, voice });
|
|
292
303
|
|
|
293
304
|
return [
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Generic "emotion tags" capability for TTS engines.
|
|
2
|
+
//
|
|
3
|
+
// Some speech backends (e.g. a local Qwen3-TTS / QVox server, reached through
|
|
4
|
+
// the OpenAI-compatible `openai` adapter pointed at a custom base_url) accept
|
|
5
|
+
// inline [tag] markers in the text and switch the speaking emotion per segment
|
|
6
|
+
// while keeping the same base voice. QVox's tag set is the canonical default
|
|
7
|
+
// (see qwen3-tts-api `_app.py` TAG_INSTRUCTS).
|
|
8
|
+
//
|
|
9
|
+
// This capability is NOT hard-coded into any adapter. It's a per-engine config
|
|
10
|
+
// toggle (`voice.tts.<id>.emotions.enabled`) so it can be ADDED to whichever
|
|
11
|
+
// engine the user actually points at an emotion-aware backend — the custom
|
|
12
|
+
// OpenAI endpoint today, anything tomorrow. Two responsibilities live here:
|
|
13
|
+
// 1. activeEmotionGuide() — what the prompt builder injects (voice mode only)
|
|
14
|
+
// so the agent learns the tag syntax, but ONLY when the engine that will
|
|
15
|
+
// speak supports tags.
|
|
16
|
+
// 2. stripEmotionTags() — a safety net so that if a turn ends up on an engine
|
|
17
|
+
// WITHOUT tag support, stray markers are scrubbed and never read aloud.
|
|
18
|
+
|
|
19
|
+
import { resolveMode, resolveChainOrder, isCustomId, CUSTOM_PREFIX } from "./engines/index.js";
|
|
20
|
+
|
|
21
|
+
// Canonical tag set (mirrors QVox TAG_INSTRUCTS keys, de-duplicated).
|
|
22
|
+
export const DEFAULT_EMOTION_TAGS = [
|
|
23
|
+
"happy", "sad", "excited", "angry", "calm",
|
|
24
|
+
"whisper", "shout", "laugh", "cry", "narrator", "neutral",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const TAG_RE = /\[[a-zA-Z]{2,12}\]/g;
|
|
28
|
+
|
|
29
|
+
function ttsCfg(globalConfig) {
|
|
30
|
+
return globalConfig?.voice?.tts || {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Per-engine config block (built-in id → voice.tts.<id>; custom:<slug> →
|
|
34
|
+
// voice.tts.custom.<slug>).
|
|
35
|
+
function providerBlock(globalConfig, providerId) {
|
|
36
|
+
const tts = ttsCfg(globalConfig);
|
|
37
|
+
if (isCustomId(providerId)) return tts?.custom?.[providerId.slice(CUSTOM_PREFIX.length)] || {};
|
|
38
|
+
return tts?.[providerId] || {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function enabledOf(tts, id) {
|
|
42
|
+
if (isCustomId(id)) return tts?.custom?.[id.slice(CUSTOM_PREFIX.length)]?.enabled !== false;
|
|
43
|
+
return tts?.[id]?.enabled !== false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Emotion capability declared for one engine id. `enabled` is false when the
|
|
48
|
+
* block is missing or turned off; `tags` falls back to the canonical set.
|
|
49
|
+
*/
|
|
50
|
+
export function emotionConfigFor(globalConfig, providerId) {
|
|
51
|
+
const e = providerBlock(globalConfig, providerId)?.emotions;
|
|
52
|
+
const enabled = !!e?.enabled;
|
|
53
|
+
const tags = Array.isArray(e?.tags) && e.tags.length
|
|
54
|
+
? e.tags.map((s) => String(s).trim().toLowerCase()).filter(Boolean)
|
|
55
|
+
: DEFAULT_EMOTION_TAGS;
|
|
56
|
+
return { enabled, tags };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Best-effort, SYNCHRONOUS resolution of which engine will speak. Used at
|
|
61
|
+
* prompt-build time (which is sync and must not probe isAvailable()). Mirrors
|
|
62
|
+
* the intent of selectTtsEngine without the async availability probes:
|
|
63
|
+
* - explicit provider arg wins
|
|
64
|
+
* - single mode → voice.tts.provider
|
|
65
|
+
* - chain mode → the FIRST enabled engine in order (what selectTtsEngine
|
|
66
|
+
* would speak with). We deliberately do NOT prefer an emotion-capable
|
|
67
|
+
* engine here: the guide must reflect the engine that will actually speak,
|
|
68
|
+
* otherwise the agent emits tags a different engine never asked for.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveSpeakingProvider(globalConfig, provider) {
|
|
71
|
+
if (provider && provider !== "auto") return provider;
|
|
72
|
+
const cfg = ttsCfg(globalConfig);
|
|
73
|
+
const mode = resolveMode(cfg);
|
|
74
|
+
if (mode === "single" && cfg.provider && cfg.provider !== "auto") return cfg.provider;
|
|
75
|
+
const order = resolveChainOrder(cfg).filter(
|
|
76
|
+
(id) => id !== "mock" && enabledOf(cfg, id)
|
|
77
|
+
);
|
|
78
|
+
return order[0] || cfg.provider || undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The emotion guide to inject for a voice-mode turn, or null when the engine
|
|
83
|
+
* that will speak does not support tags. `provider` optionally forces a
|
|
84
|
+
* specific engine (e.g. a tester override).
|
|
85
|
+
*/
|
|
86
|
+
export function activeEmotionGuide(globalConfig, provider) {
|
|
87
|
+
const id = resolveSpeakingProvider(globalConfig, provider);
|
|
88
|
+
if (!id) return null;
|
|
89
|
+
const { enabled, tags } = emotionConfigFor(globalConfig, id);
|
|
90
|
+
return enabled ? { provider: id, tags } : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Markdown block teaching the inline-tag syntax. Appended to modes/voice.md. */
|
|
94
|
+
export function buildEmotionGuide(tags = DEFAULT_EMOTION_TAGS) {
|
|
95
|
+
const list = (Array.isArray(tags) && tags.length ? tags : DEFAULT_EMOTION_TAGS).join("] [");
|
|
96
|
+
return [
|
|
97
|
+
"## Emotion tags (spoken delivery)",
|
|
98
|
+
"The voice engine for this turn understands inline emotion tags. You MAY drop them into your spoken reply to color the delivery — a tag affects the words that follow it, until the next tag.",
|
|
99
|
+
`Available tags: [${list}]`,
|
|
100
|
+
'Write the tag in square brackets right before the phrase it colors (e.g. "[excited] ¡Listo! [calm] Lo dejé anotado."). Use them sparingly — at most one or two per reply, only when the emotion genuinely helps. The tags are removed before synthesis (never spoken). Never invent tags outside the list above.',
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remove stray [tag] markers so a non-tag engine never reads them aloud. */
|
|
105
|
+
export function stripEmotionTags(text) {
|
|
106
|
+
if (typeof text !== "string") return text;
|
|
107
|
+
return text.replace(TAG_RE, "").replace(/[ \t]{2,}/g, " ").trim();
|
|
108
|
+
}
|
|
@@ -28,7 +28,28 @@ export const TTS_ENGINE_IDS = Object.keys(ADAPTERS);
|
|
|
28
28
|
|
|
29
29
|
export const AUTO_PREFERENCE = ["piper", "elevenlabs", "openai", "gemini", "mock"];
|
|
30
30
|
|
|
31
|
+
// ── Custom providers ────────────────────────────────────────────────────────
|
|
32
|
+
// Users can add any number of OpenAI-compatible endpoints (e.g. a local QVox /
|
|
33
|
+
// Qwen3-TTS server). They live under voice.tts.custom.<slug> and surface with
|
|
34
|
+
// engine id "custom:<slug>". They're all backed by the openai adapter.
|
|
35
|
+
export const CUSTOM_PREFIX = "custom:";
|
|
36
|
+
|
|
37
|
+
export function isCustomId(id) {
|
|
38
|
+
return typeof id === "string" && id.startsWith(CUSTOM_PREFIX);
|
|
39
|
+
}
|
|
40
|
+
function slugOf(id) {
|
|
41
|
+
return isCustomId(id) ? id.slice(CUSTOM_PREFIX.length) : id;
|
|
42
|
+
}
|
|
43
|
+
function customEngineIds(ttsCfg) {
|
|
44
|
+
return Object.keys(ttsCfg?.custom || {}).map((slug) => CUSTOM_PREFIX + slug);
|
|
45
|
+
}
|
|
46
|
+
function knownIds(ttsCfg) {
|
|
47
|
+
return [...TTS_ENGINE_IDS, ...customEngineIds(ttsCfg)];
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
export function getTtsAdapter(provider) {
|
|
51
|
+
// All custom providers are OpenAI-compatible → openai adapter.
|
|
52
|
+
if (isCustomId(provider)) return ADAPTERS.openai;
|
|
32
53
|
const a = ADAPTERS[provider];
|
|
33
54
|
if (!a) {
|
|
34
55
|
throw new Error(
|
|
@@ -43,10 +64,13 @@ function ttsConfig(globalConfig) {
|
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
function providerConfig(globalConfig, provider) {
|
|
46
|
-
|
|
67
|
+
const tts = ttsConfig(globalConfig);
|
|
68
|
+
if (isCustomId(provider)) return tts?.custom?.[slugOf(provider)] || {};
|
|
69
|
+
return tts?.[provider] || {};
|
|
47
70
|
}
|
|
48
71
|
|
|
49
72
|
function isEnabled(ttsCfg, id) {
|
|
73
|
+
if (isCustomId(id)) return ttsCfg?.custom?.[slugOf(id)]?.enabled !== false;
|
|
50
74
|
return ttsCfg?.[id]?.enabled !== false;
|
|
51
75
|
}
|
|
52
76
|
|
|
@@ -63,11 +87,14 @@ export function resolveMode(ttsCfg) {
|
|
|
63
87
|
* so the UI can render + reorder every row; filtering happens at selection time.
|
|
64
88
|
*/
|
|
65
89
|
export function resolveChainOrder(ttsCfg) {
|
|
66
|
-
const
|
|
67
|
-
|
|
90
|
+
const known = knownIds(ttsCfg);
|
|
91
|
+
const ordered = Array.isArray(ttsCfg?.order)
|
|
92
|
+
? ttsCfg.order.filter((id) => known.includes(id))
|
|
68
93
|
: [];
|
|
69
|
-
const rest = AUTO_PREFERENCE
|
|
70
|
-
|
|
94
|
+
const rest = [...AUTO_PREFERENCE, ...customEngineIds(ttsCfg)].filter(
|
|
95
|
+
(id) => !ordered.includes(id)
|
|
96
|
+
);
|
|
97
|
+
const full = [...ordered, ...rest];
|
|
71
98
|
// Guarantee mock is present as the ultimate fallback.
|
|
72
99
|
if (!full.includes("mock")) full.push("mock");
|
|
73
100
|
return full;
|
|
@@ -101,7 +128,7 @@ export async function selectTtsEngine({ globalConfig, provider }) {
|
|
|
101
128
|
// 3. Chain mode: probe the (enabled) order, first available wins.
|
|
102
129
|
for (const id of resolveChainOrder(ttsCfg)) {
|
|
103
130
|
if (id !== "mock" && !isEnabled(ttsCfg, id)) continue;
|
|
104
|
-
const adapter =
|
|
131
|
+
const adapter = getTtsAdapter(id);
|
|
105
132
|
const cfg = providerConfig(globalConfig, id);
|
|
106
133
|
try {
|
|
107
134
|
if (await adapter.isAvailable(cfg, globalConfig?.engines)) {
|
|
@@ -124,20 +151,23 @@ export async function selectTtsEngine({ globalConfig, provider }) {
|
|
|
124
151
|
export async function listAvailableTtsEngines(globalConfig) {
|
|
125
152
|
const ttsCfg = ttsConfig(globalConfig);
|
|
126
153
|
const out = [];
|
|
127
|
-
for (const id of
|
|
128
|
-
const adapter =
|
|
154
|
+
for (const id of knownIds(ttsCfg)) {
|
|
155
|
+
const adapter = getTtsAdapter(id);
|
|
129
156
|
const cfg = providerConfig(globalConfig, id);
|
|
130
157
|
let available = false;
|
|
131
158
|
try {
|
|
132
159
|
available = await adapter.isAvailable(cfg, globalConfig?.engines);
|
|
133
160
|
} catch { available = false; }
|
|
161
|
+
const custom = isCustomId(id);
|
|
134
162
|
out.push({
|
|
135
163
|
id,
|
|
136
164
|
available,
|
|
137
165
|
// `enabled` is a routing flag, not real config — exclude it from the
|
|
138
166
|
// "configured" heuristic so toggling on/off doesn't fake-mark an engine.
|
|
139
|
-
|
|
167
|
+
// For custom engines `label` is descriptive metadata, not config either.
|
|
168
|
+
configured: Object.keys(cfg).filter((k) => k !== "enabled" && k !== "label").length > 0,
|
|
140
169
|
enabled: isEnabled(ttsCfg, id),
|
|
170
|
+
...(custom ? { custom: true, label: cfg.label || slugOf(id), note: cfg.base_url || "" } : {}),
|
|
141
171
|
});
|
|
142
172
|
}
|
|
143
173
|
return out;
|
|
@@ -1,18 +1,29 @@
|
|
|
1
|
-
// OpenAI TTS adapter (tts-1 / tts-1-hd).
|
|
1
|
+
// OpenAI TTS adapter (tts-1 / tts-1-hd) — and any OpenAI-compatible endpoint.
|
|
2
2
|
// Docs: https://platform.openai.com/docs/api-reference/audio/createSpeech
|
|
3
3
|
//
|
|
4
4
|
// Reuses engines.openai.api_key from ~/.apx/config.json. Per-engine voice
|
|
5
5
|
// config (~/.apx/config.json → voice.tts.openai) can override model/voice.
|
|
6
|
+
//
|
|
7
|
+
// Custom endpoint ("QVox custom"): set voice.tts.openai.base_url to a local
|
|
8
|
+
// OpenAI-compatible speech server (e.g. a Qwen3-TTS / QVox daemon at
|
|
9
|
+
// http://127.0.0.1:5111/v1). When base_url is set we additionally forward the
|
|
10
|
+
// non-OpenAI fields that server understands — `instruct` (the base voice, from
|
|
11
|
+
// the `style` arg), `language` and `temperature`. These extras are NEVER sent
|
|
12
|
+
// to stock OpenAI (only when base_url is present), so the standard path stays
|
|
13
|
+
// byte-for-byte compatible.
|
|
6
14
|
|
|
7
15
|
import fs from "node:fs";
|
|
8
16
|
import path from "node:path";
|
|
9
17
|
import { randomUUID } from "node:crypto";
|
|
10
18
|
|
|
11
|
-
const
|
|
19
|
+
const DEFAULT_API_URL = "https://api.openai.com/v1/audio/speech";
|
|
12
20
|
const DEFAULT_MODEL = "tts-1";
|
|
13
21
|
const DEFAULT_VOICE = "alloy"; // alloy|echo|fable|onyx|nova|shimmer
|
|
14
22
|
|
|
15
23
|
function getKey(config, parentEnginesCfg) {
|
|
24
|
+
// A custom endpoint uses ONLY its own key (often none); never leak the stock
|
|
25
|
+
// OpenAI engine key / OPENAI_API_KEY env to a third-party server.
|
|
26
|
+
if (config.base_url) return config.api_key || "";
|
|
16
27
|
return (
|
|
17
28
|
config.api_key ||
|
|
18
29
|
parentEnginesCfg?.openai?.api_key ||
|
|
@@ -21,6 +32,13 @@ function getKey(config, parentEnginesCfg) {
|
|
|
21
32
|
);
|
|
22
33
|
}
|
|
23
34
|
|
|
35
|
+
function endpoint(config) {
|
|
36
|
+
if (config.base_url) {
|
|
37
|
+
return config.base_url.replace(/\/+$/, "") + "/audio/speech";
|
|
38
|
+
}
|
|
39
|
+
return DEFAULT_API_URL;
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
function mimeFor(format) {
|
|
25
43
|
return {
|
|
26
44
|
mp3: "audio/mpeg",
|
|
@@ -36,30 +54,48 @@ export default {
|
|
|
36
54
|
id: "openai",
|
|
37
55
|
|
|
38
56
|
async isAvailable(config = {}, parentEnginesCfg) {
|
|
39
|
-
|
|
57
|
+
// A custom endpoint is assumed reachable (it may be keyless/open like QVox);
|
|
58
|
+
// stock OpenAI needs a key.
|
|
59
|
+
return Boolean(config.base_url) || Boolean(getKey(config, parentEnginesCfg));
|
|
40
60
|
},
|
|
41
61
|
|
|
42
|
-
async synthesize({ text, voice, outDir, config = {}, format, signal, parentEnginesCfg }) {
|
|
62
|
+
async synthesize({ text, voice, language, style, outDir, config = {}, format, signal, parentEnginesCfg }) {
|
|
43
63
|
if (!text) throw new Error("openai-tts: empty text");
|
|
64
|
+
const isCustom = Boolean(config.base_url);
|
|
44
65
|
const key = getKey(config, parentEnginesCfg);
|
|
45
|
-
if (!key)
|
|
66
|
+
if (!isCustom && !key) {
|
|
67
|
+
throw new Error("openai-tts: no api_key (set OPENAI_API_KEY or engines.openai.api_key)");
|
|
68
|
+
}
|
|
46
69
|
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
70
|
+
const url = endpoint(config);
|
|
71
|
+
const model = config.model || (isCustom ? undefined : DEFAULT_MODEL);
|
|
72
|
+
const chosenVoice = voice || config.voice || (isCustom ? undefined : DEFAULT_VOICE);
|
|
73
|
+
const responseFormat = format || config.format || (isCustom ? "wav" : "mp3");
|
|
74
|
+
const styleHint = String(style ?? config.style ?? "").trim();
|
|
75
|
+
|
|
76
|
+
const body = { input: text, response_format: responseFormat };
|
|
77
|
+
if (model) body.model = model;
|
|
78
|
+
if (chosenVoice) body.voice = chosenVoice;
|
|
79
|
+
if (isCustom) {
|
|
80
|
+
// QVox / Qwen3-TTS extras (ignored by stock OpenAI, so only sent here).
|
|
81
|
+
if (styleHint) body.instruct = styleHint;
|
|
82
|
+
if (language) body.language = language;
|
|
83
|
+
if (config.temperature != null) body.temperature = config.temperature;
|
|
84
|
+
} else if (styleHint && /gpt-4o.*tts/i.test(model || "")) {
|
|
85
|
+
// Stock OpenAI's newer TTS models accept a natural-language `instructions`.
|
|
86
|
+
body.instructions = styleHint;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const headers = { "content-type": "application/json" };
|
|
90
|
+
if (key) {
|
|
91
|
+
headers.authorization = `Bearer ${key}`;
|
|
92
|
+
if (isCustom) headers["x-api-key"] = key; // QVox accepts either header.
|
|
93
|
+
}
|
|
50
94
|
|
|
51
|
-
const res = await fetch(
|
|
95
|
+
const res = await fetch(url, {
|
|
52
96
|
method: "POST",
|
|
53
|
-
headers
|
|
54
|
-
|
|
55
|
-
"content-type": "application/json",
|
|
56
|
-
},
|
|
57
|
-
body: JSON.stringify({
|
|
58
|
-
model,
|
|
59
|
-
voice: chosenVoice,
|
|
60
|
-
input: text,
|
|
61
|
-
response_format: responseFormat,
|
|
62
|
-
}),
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify(body),
|
|
63
99
|
signal,
|
|
64
100
|
});
|
|
65
101
|
if (!res.ok) {
|
package/src/core/voice/tts.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolveMode,
|
|
18
18
|
resolveChainOrder,
|
|
19
19
|
} from "./engines/index.js";
|
|
20
|
+
import { emotionConfigFor, stripEmotionTags } from "./emotions.js";
|
|
20
21
|
|
|
21
22
|
export const TTS_TMP_DIR = path.join(os.homedir(), ".apx", "tmp", "tts");
|
|
22
23
|
|
|
@@ -61,9 +62,15 @@ export async function synthesize({
|
|
|
61
62
|
provider,
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
// Safety net: if the engine that will speak does NOT support inline emotion
|
|
66
|
+
// tags, scrub any stray [tag] markers so they're never read aloud literally.
|
|
67
|
+
const speakText = emotionConfigFor(cfg, selectedProvider).enabled
|
|
68
|
+
? text
|
|
69
|
+
: stripEmotionTags(text);
|
|
70
|
+
|
|
64
71
|
const outDir = ensureTtsTmpDir();
|
|
65
72
|
return adapter.synthesize({
|
|
66
|
-
text,
|
|
73
|
+
text: speakText,
|
|
67
74
|
voice,
|
|
68
75
|
language,
|
|
69
76
|
format,
|
|
@@ -72,7 +79,7 @@ export async function synthesize({
|
|
|
72
79
|
config: engineConfig,
|
|
73
80
|
parentEnginesCfg: cfg.engines,
|
|
74
81
|
signal,
|
|
75
|
-
}).then((r) => ({ ...r, provider: r.provider
|
|
82
|
+
}).then((r) => ({ ...r, provider: selectedProvider || r.provider }));
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
/** List engines and whether they look usable right now. */
|
|
@@ -22,6 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { readConfig } from "#core/config/index.js";
|
|
24
24
|
import { synthesize } from "#core/voice/tts.js";
|
|
25
|
+
import { stripEmotionTags } from "#core/voice/emotions.js";
|
|
25
26
|
import { transcribe } from "#core/voice/transcription.js";
|
|
26
27
|
import { decodeAudioInput } from "#core/voice/audio-decode.js";
|
|
27
28
|
import { runSuperAgent, isSuperAgentEnabled } from "#core/agent/super-agent.js";
|
|
@@ -174,6 +175,12 @@ export function register(app, { projects, plugins, registries }) {
|
|
|
174
175
|
replyText = userText;
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
// Emotion tags ([excited], [whisper], …) are a TTS-only signal: keep them
|
|
179
|
+
// for synthesis (engines that support them split on them), but strip them
|
|
180
|
+
// from everything the user reads — the chat bubble, history, RAG.
|
|
181
|
+
const audioText = replyText;
|
|
182
|
+
replyText = stripEmotionTags(replyText);
|
|
183
|
+
|
|
177
184
|
// Persist the turn to the cross-channel store (feeds RAG index,
|
|
178
185
|
// search_messages, and the "active threads" block). channelCtx.channel is
|
|
179
186
|
// the resolved surface ("deck"/"desktop"). Best-effort.
|
|
@@ -190,10 +197,11 @@ export function register(app, { projects, plugins, registries }) {
|
|
|
190
197
|
if (replyText) {
|
|
191
198
|
try {
|
|
192
199
|
tts = await synthesize({
|
|
193
|
-
text:
|
|
200
|
+
text: audioText,
|
|
194
201
|
voice: body.voice,
|
|
195
202
|
language: body.language,
|
|
196
203
|
provider: body.provider,
|
|
204
|
+
style: body.style,
|
|
197
205
|
format: body.format_out,
|
|
198
206
|
globalConfig: cfg,
|
|
199
207
|
});
|