@openbrt/audioctl 0.1.12 → 0.1.13
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 +68 -2
- package/agent-plugin.json +14 -0
- package/bin/audioctl.mjs +60 -0
- package/lib/index.mjs +201 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,10 @@ audioctl light set all --color amber --brightness 0.35
|
|
|
83
83
|
audioctl light timeline ./hero.light.json
|
|
84
84
|
audioctl light bind ./hero.light-bindings.json
|
|
85
85
|
audioctl light clear
|
|
86
|
+
audioctl wakeword status --json
|
|
87
|
+
audioctl wakeword configure ./wakeword.json --json
|
|
88
|
+
audioctl wakeword bind ./wakeword-bindings.json --json
|
|
89
|
+
audioctl wakeword trigger work_mode --source agent --json
|
|
86
90
|
audioctl firmware check --json
|
|
87
91
|
audioctl firmware update --yes --json
|
|
88
92
|
audioctl feedback signal ready
|
|
@@ -103,6 +107,41 @@ provisioning state, wake-word capability, playback, VM/app status, firmware
|
|
|
103
107
|
runtime version, and health without receiving Wi-Fi passwords, MQTT
|
|
104
108
|
credentials, or music-platform secrets.
|
|
105
109
|
|
|
110
|
+
`wakeword` is the logical voice-trigger binding layer. It does not train or
|
|
111
|
+
replace the low-level acoustic wake model. It maps existing device wake events,
|
|
112
|
+
ASR phrases, or agent-injected test phrases to VM/app events or safe local
|
|
113
|
+
actions. Agents should read `audioctl inspect --json` first and use reported
|
|
114
|
+
`wakeword.commands` / `voice.wakeword.*` capabilities, not product-specific
|
|
115
|
+
vendor research.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
audioctl wakeword status --json
|
|
119
|
+
audioctl wakeword configure ./wakeword.json --json
|
|
120
|
+
audioctl wakeword bind ./wakeword-bindings.json --json
|
|
121
|
+
audioctl wakeword trigger work_mode --source agent --json
|
|
122
|
+
audioctl wakeword clear --json
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Example wakeword config:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"schema": "weclawbot.wakeword.config.v1",
|
|
130
|
+
"mode": "logical_voice_trigger",
|
|
131
|
+
"language": "zh",
|
|
132
|
+
"phrases": [
|
|
133
|
+
{ "id": "work_mode", "text": "开始工作", "aliases": ["工作模式"] }
|
|
134
|
+
],
|
|
135
|
+
"bindings": [
|
|
136
|
+
{
|
|
137
|
+
"phrase_id": "work_mode",
|
|
138
|
+
"action": "app.event",
|
|
139
|
+
"feedback": ["feedback.beep", "feedback.signal:wake"]
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
106
145
|
`firmware check` reads the official firmware index from
|
|
107
146
|
`https://weclawbot.link/firmware/audio/manifest.json`, compares it with
|
|
108
147
|
`inspect.firmware.version`, and reports whether a host/runtime update is
|
|
@@ -285,6 +324,33 @@ Light messages use a distinct command envelope:
|
|
|
285
324
|
Supported light commands are `describe`, `set`, `timeline`, `bind`, and
|
|
286
325
|
`clear`. Continuous light timing and event reactions run on the device VM.
|
|
287
326
|
|
|
327
|
+
Wakeword messages configure the logical voice-trigger layer:
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
{
|
|
331
|
+
"schema": "weclawbot.control.v1",
|
|
332
|
+
"id": "wakeword_<uuid>",
|
|
333
|
+
"kind": "wakeword_command",
|
|
334
|
+
"wakeword": {
|
|
335
|
+
"command": "configure",
|
|
336
|
+
"config": {
|
|
337
|
+
"schema": "weclawbot.wakeword.config.v1",
|
|
338
|
+
"mode": "logical_voice_trigger",
|
|
339
|
+
"phrases": [
|
|
340
|
+
{ "id": "work_mode", "text": "开始工作" }
|
|
341
|
+
],
|
|
342
|
+
"bindings": [
|
|
343
|
+
{ "phrase_id": "work_mode", "action": "app.event" }
|
|
344
|
+
]
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Supported wakeword commands are `status`, `configure`, `bind`, `trigger`, and
|
|
351
|
+
`clear`. This is a VM/app event binding layer, not low-level acoustic-model
|
|
352
|
+
training.
|
|
353
|
+
|
|
288
354
|
Firmware update messages also use the same MQTT envelope, but carry only an
|
|
289
355
|
official release descriptor. The device downloads the artifact itself from
|
|
290
356
|
`weclawbot.link`, enforces HTTPS, checks SHA-256 and byte size, installs from a
|
|
@@ -303,10 +369,10 @@ staging directory, restarts local services, and reports health through
|
|
|
303
369
|
"schema": "weclawbot.firmware.release.v1",
|
|
304
370
|
"device_class": "audio_voice_ambient_endpoint",
|
|
305
371
|
"runtime": "rokid_music_runtime",
|
|
306
|
-
"version": "0.1.
|
|
372
|
+
"version": "0.1.2",
|
|
307
373
|
"channel": "stable",
|
|
308
374
|
"artifact": {
|
|
309
|
-
"url": "https://weclawbot.link/firmware/audio/rokid-music-runtime-0.1.
|
|
375
|
+
"url": "https://weclawbot.link/firmware/audio/rokid-music-runtime-0.1.2.tar",
|
|
310
376
|
"sha256": "<64 hex chars>",
|
|
311
377
|
"bytes": 123456
|
|
312
378
|
}
|
package/agent-plugin.json
CHANGED
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
"light.timeline",
|
|
83
83
|
"light.bind",
|
|
84
84
|
"light.clear",
|
|
85
|
+
"voice.wakeword.configure",
|
|
86
|
+
"voice.wakeword.bind",
|
|
87
|
+
"voice.wakeword.trigger",
|
|
88
|
+
"voice.event.bind",
|
|
85
89
|
"event.bind",
|
|
86
90
|
"iot.control",
|
|
87
91
|
"device.inspect",
|
|
@@ -121,6 +125,12 @@
|
|
|
121
125
|
"primary_command": "audioctl app install MANIFEST.json APP.wasm",
|
|
122
126
|
"agent_strategy": "不要把设备假定为音乐播放器。先运行 audioctl inspect --json 读取设备能力,再生成声明最小 capabilities 的 VM app。语音、TTS、家居控制等能力必须通过 manifest capabilities 显式声明;设备侧只执行授权范围内的 I/O。"
|
|
123
127
|
},
|
|
128
|
+
{
|
|
129
|
+
"intent": "配置唤醒词或语音短语来执行某个操作",
|
|
130
|
+
"primary_command": "audioctl wakeword configure WAKEWORD.json --json",
|
|
131
|
+
"agent_strategy": "先运行 audioctl inspect --json 读取 wakeword.configurable、wakeword.commands、voice.wakeword.* 和 voice.event.bind capabilities。把 wakeword 视为逻辑语音触发绑定层:它把设备已有唤醒事件、ASR 短语或 agent 注入的测试短语映射到 VM/app 事件或本地动作。不要搜索品牌/型号/厂家资料,不要尝试训练或替换底层声学唤醒模型。需要让 VM app 处理时,在 app manifest 里声明最小 voice.wakeword.bind / voice.event.bind 能力和 wakeword_bindings;需要只测试触发时用 audioctl wakeword trigger WORD --json。",
|
|
132
|
+
"if_missing_capability": "如果 inspect 未报告 wakeword.configurable 或 voice.wakeword.*,先运行 audioctl firmware check --json;发现官网新版后向用户说明并请求确认升级。"
|
|
133
|
+
},
|
|
124
134
|
{
|
|
125
135
|
"intent": "检查并升级设备固件",
|
|
126
136
|
"primary_command": "audioctl firmware check --json",
|
|
@@ -154,6 +164,10 @@
|
|
|
154
164
|
"name": "light",
|
|
155
165
|
"usage": "audioctl light describe | set SURFACE --color COLOR [--brightness 0..1] | timeline TIMELINE.json | bind BINDINGS.json | clear"
|
|
156
166
|
},
|
|
167
|
+
{
|
|
168
|
+
"name": "wakeword",
|
|
169
|
+
"usage": "audioctl wakeword status | configure CONFIG.json | bind BINDINGS.json | trigger WORD [--source NAME] | clear"
|
|
170
|
+
},
|
|
157
171
|
{
|
|
158
172
|
"name": "led",
|
|
159
173
|
"usage": "audioctl led flash COLOR [--repeat N]"
|
package/bin/audioctl.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
buildFirmwareUpdateControl,
|
|
19
19
|
buildLightControl,
|
|
20
20
|
buildQueueControl,
|
|
21
|
+
buildWakewordControl,
|
|
21
22
|
buildWorkBgmAppInstallControl,
|
|
22
23
|
expandPath,
|
|
23
24
|
normalizeCredentials,
|
|
@@ -61,6 +62,7 @@ async function run(name, values) {
|
|
|
61
62
|
if (name === "volume") return commandVolume(values);
|
|
62
63
|
if (name === "seek") return commandSeek(values);
|
|
63
64
|
if (name === "light") return commandLight(values);
|
|
65
|
+
if (name === "wakeword" || name === "voice") return commandWakeword(values);
|
|
64
66
|
if (name === "feedback" || name === "led") return commandFeedback(values);
|
|
65
67
|
return commandControl(name, values);
|
|
66
68
|
}
|
|
@@ -85,6 +87,11 @@ function usage(exitCode = 0) {
|
|
|
85
87
|
${COMMAND_NAME} light timeline TIMELINE.json
|
|
86
88
|
${COMMAND_NAME} light bind BINDINGS.json
|
|
87
89
|
${COMMAND_NAME} light clear
|
|
90
|
+
${COMMAND_NAME} wakeword status
|
|
91
|
+
${COMMAND_NAME} wakeword configure CONFIG.json
|
|
92
|
+
${COMMAND_NAME} wakeword bind BINDINGS.json
|
|
93
|
+
${COMMAND_NAME} wakeword trigger WORD [--source NAME]
|
|
94
|
+
${COMMAND_NAME} wakeword clear
|
|
88
95
|
${COMMAND_NAME} firmware check [--manifest URL]
|
|
89
96
|
${COMMAND_NAME} firmware update [--manifest URL] [--yes]
|
|
90
97
|
${COMMAND_NAME} rule RULE.json
|
|
@@ -456,6 +463,56 @@ async function commandLight(values) {
|
|
|
456
463
|
if (delivery.status?.kind === "rejected") process.exitCode = 1;
|
|
457
464
|
}
|
|
458
465
|
|
|
466
|
+
async function commandWakeword(values) {
|
|
467
|
+
const subcommand = String(values[0] || "status").replace(/-/gu, "_").toLowerCase();
|
|
468
|
+
const options = parseOptions(values.slice(1), {
|
|
469
|
+
credentials: credentialsPath(),
|
|
470
|
+
timeout: 12,
|
|
471
|
+
json: false,
|
|
472
|
+
nowait: false,
|
|
473
|
+
source: "agent",
|
|
474
|
+
});
|
|
475
|
+
let control;
|
|
476
|
+
if (subcommand === "status" || subcommand === "describe" || subcommand === "info") {
|
|
477
|
+
control = buildWakewordControl("status");
|
|
478
|
+
} else if (subcommand === "configure" || subcommand === "config" || subcommand === "set") {
|
|
479
|
+
const file = String(options._[0] || "");
|
|
480
|
+
if (!file) throw new Error("wakeword_configure_requires_file");
|
|
481
|
+
control = buildWakewordControl("configure", {
|
|
482
|
+
config: JSON.parse(await fs.readFile(file, "utf8")),
|
|
483
|
+
});
|
|
484
|
+
} else if (subcommand === "bind" || subcommand === "bindings") {
|
|
485
|
+
const file = String(options._[0] || "");
|
|
486
|
+
if (!file) throw new Error("wakeword_bind_requires_file");
|
|
487
|
+
const payload = JSON.parse(await fs.readFile(file, "utf8"));
|
|
488
|
+
control = buildWakewordControl("bind", {
|
|
489
|
+
bindings: Array.isArray(payload) ? payload : payload.bindings,
|
|
490
|
+
});
|
|
491
|
+
} else if (subcommand === "trigger" || subcommand === "test" || subcommand === "event") {
|
|
492
|
+
control = buildWakewordControl("trigger", {
|
|
493
|
+
word: options._[0] || options.word || options.phrase || "factory_wake",
|
|
494
|
+
source: options.source,
|
|
495
|
+
});
|
|
496
|
+
} else if (subcommand === "clear" || subcommand === "reset") {
|
|
497
|
+
control = buildWakewordControl("clear");
|
|
498
|
+
} else {
|
|
499
|
+
throw new Error(`wakeword_subcommand_unsupported: ${subcommand}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const credentials = await readCredentials(options.credentials);
|
|
503
|
+
const delivery = await publishAudioControl(credentials, control, {
|
|
504
|
+
timeoutMs: Number(options.timeout) * 1000,
|
|
505
|
+
wait: !options.nowait,
|
|
506
|
+
});
|
|
507
|
+
if (control.wakeword.command === "status" && options.json) {
|
|
508
|
+
const wakeword = delivery.status?.inspect?.wakeword || delivery.status?.wakeword || {};
|
|
509
|
+
process.stdout.write(`${JSON.stringify(redactAgentFacingValue(wakeword))}\n`);
|
|
510
|
+
} else {
|
|
511
|
+
print(delivery.status || delivery, options.json);
|
|
512
|
+
}
|
|
513
|
+
if (delivery.status?.kind === "rejected") process.exitCode = 1;
|
|
514
|
+
}
|
|
515
|
+
|
|
459
516
|
async function commandFeedback(values) {
|
|
460
517
|
const subcommand = String(values[0] || "").replace(/-/gu, "_").toLowerCase();
|
|
461
518
|
const shorthandSignals = new Set([
|
|
@@ -586,9 +643,12 @@ function parseOptions(values, defaults = {}) {
|
|
|
586
643
|
"pattern",
|
|
587
644
|
"profile",
|
|
588
645
|
"repeat",
|
|
646
|
+
"phrase",
|
|
647
|
+
"source",
|
|
589
648
|
"surface",
|
|
590
649
|
"timeout",
|
|
591
650
|
"volume",
|
|
651
|
+
"word",
|
|
592
652
|
]).has(key)) {
|
|
593
653
|
throw new Error(`option_unknown: --${key}`);
|
|
594
654
|
}
|
package/lib/index.mjs
CHANGED
|
@@ -52,6 +52,7 @@ export const WORKBGM_RULE = Object.freeze({
|
|
|
52
52
|
export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
|
|
53
53
|
export const LIGHT_TIMELINE_SCHEMA = "weclawbot.light.timeline.v1";
|
|
54
54
|
export const LIGHT_BINDINGS_SCHEMA = "weclawbot.light.bind.v1";
|
|
55
|
+
export const WAKEWORD_CONFIG_SCHEMA = "weclawbot.wakeword.config.v1";
|
|
55
56
|
export const FIRMWARE_INDEX_SCHEMA = "weclawbot.firmware.index.v1";
|
|
56
57
|
export const FIRMWARE_RELEASE_SCHEMA = "weclawbot.firmware.release.v1";
|
|
57
58
|
|
|
@@ -106,6 +107,13 @@ export const LIGHT_EVENTS = Object.freeze([
|
|
|
106
107
|
"input.ring.single_click",
|
|
107
108
|
"input.ring.clockwise",
|
|
108
109
|
"input.ring.counter_clockwise",
|
|
110
|
+
"voice.wakeword.triggered",
|
|
111
|
+
"voice.phrase.matched",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
export const WAKEWORD_EVENTS = Object.freeze([
|
|
115
|
+
"voice.wakeword.triggered",
|
|
116
|
+
"voice.phrase.matched",
|
|
109
117
|
]);
|
|
110
118
|
|
|
111
119
|
const WORKBGM_APP_WASM_BASE64 =
|
|
@@ -257,6 +265,16 @@ export function buildLightControl(command, options = {}) {
|
|
|
257
265
|
};
|
|
258
266
|
}
|
|
259
267
|
|
|
268
|
+
export function buildWakewordControl(command, options = {}) {
|
|
269
|
+
const wakeword = normalizeWakewordCommand(command, options);
|
|
270
|
+
return {
|
|
271
|
+
schema: "weclawbot.control.v1",
|
|
272
|
+
id: string(options.id) || `wakeword_${crypto.randomUUID()}`,
|
|
273
|
+
kind: "wakeword_command",
|
|
274
|
+
wakeword,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
260
278
|
export function buildQueueControl(options = {}) {
|
|
261
279
|
return buildAudioControl("set_queue", {
|
|
262
280
|
id: options.id,
|
|
@@ -719,6 +737,157 @@ function normalizeLightEvent(value) {
|
|
|
719
737
|
return event;
|
|
720
738
|
}
|
|
721
739
|
|
|
740
|
+
export function normalizeWakewordCommand(command, options = {}) {
|
|
741
|
+
const aliases = new Map([
|
|
742
|
+
["info", "status"],
|
|
743
|
+
["describe", "status"],
|
|
744
|
+
["config", "configure"],
|
|
745
|
+
["set", "configure"],
|
|
746
|
+
["update", "configure"],
|
|
747
|
+
["bindings", "bind"],
|
|
748
|
+
["binding", "bind"],
|
|
749
|
+
["event", "trigger"],
|
|
750
|
+
["phrase", "trigger"],
|
|
751
|
+
["test", "trigger"],
|
|
752
|
+
["reset", "clear"],
|
|
753
|
+
]);
|
|
754
|
+
const raw = string(command || options.command || options.action || "status")
|
|
755
|
+
.replace(/-/gu, "_")
|
|
756
|
+
.toLowerCase();
|
|
757
|
+
const normalized = aliases.get(raw) || raw;
|
|
758
|
+
if (!new Set(["status", "configure", "bind", "trigger", "clear"]).has(normalized)) {
|
|
759
|
+
throw new Error(`wakeword_command_unsupported: ${command}`);
|
|
760
|
+
}
|
|
761
|
+
const wakeword = { command: normalized };
|
|
762
|
+
if (normalized === "configure") {
|
|
763
|
+
wakeword.config = normalizeWakewordConfig(
|
|
764
|
+
options.config && typeof options.config === "object" ? options.config : options,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (normalized === "bind") {
|
|
768
|
+
const source =
|
|
769
|
+
Array.isArray(options.bindings)
|
|
770
|
+
? { bindings: options.bindings }
|
|
771
|
+
: Array.isArray(options.binding)
|
|
772
|
+
? { bindings: options.binding }
|
|
773
|
+
: options.bindings && typeof options.bindings === "object"
|
|
774
|
+
? options.bindings
|
|
775
|
+
: options.binding && typeof options.binding === "object"
|
|
776
|
+
? options.binding
|
|
777
|
+
: options.config && typeof options.config === "object"
|
|
778
|
+
? options.config
|
|
779
|
+
: options;
|
|
780
|
+
wakeword.bindings = Array.isArray(source.bindings)
|
|
781
|
+
? source.bindings.slice(0, 32).map(normalizeWakewordBinding)
|
|
782
|
+
: [];
|
|
783
|
+
}
|
|
784
|
+
if (normalized === "trigger") {
|
|
785
|
+
const word = string(
|
|
786
|
+
options.word ||
|
|
787
|
+
options.phrase ||
|
|
788
|
+
options.phrase_id ||
|
|
789
|
+
options.phraseId ||
|
|
790
|
+
options.value ||
|
|
791
|
+
"factory_wake",
|
|
792
|
+
);
|
|
793
|
+
wakeword.word = word.slice(0, 80) || "factory_wake";
|
|
794
|
+
wakeword.source = string(options.source || "agent").slice(0, 80) || "agent";
|
|
795
|
+
}
|
|
796
|
+
return wakeword;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export function normalizeWakewordConfig(value = {}) {
|
|
800
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
801
|
+
throw new Error("wakeword_config_invalid");
|
|
802
|
+
}
|
|
803
|
+
const phrasesInput = Array.isArray(value.phrases) ? value.phrases : [];
|
|
804
|
+
const bindingsInput = Array.isArray(value.bindings) ? value.bindings : [];
|
|
805
|
+
return {
|
|
806
|
+
schema: WAKEWORD_CONFIG_SCHEMA,
|
|
807
|
+
mode: "logical_voice_trigger",
|
|
808
|
+
language: string(value.language || "zh").slice(0, 16),
|
|
809
|
+
runtime_detection: "factory_wake_or_agent_injected_phrase",
|
|
810
|
+
phrases: phrasesInput.slice(0, 12).map(normalizeWakewordPhrase),
|
|
811
|
+
bindings: bindingsInput.slice(0, 32).map(normalizeWakewordBinding),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export function normalizeWakewordPhrase(value, index = 0) {
|
|
816
|
+
const text = string(value?.text || value?.phrase || value?.word);
|
|
817
|
+
const id = normalizeWakewordIdentifier(
|
|
818
|
+
value?.id || value?.phrase_id,
|
|
819
|
+
`phrase_${index + 1}`,
|
|
820
|
+
);
|
|
821
|
+
if (!text || text.length > 40) {
|
|
822
|
+
throw new Error(`wakeword_phrase_invalid:${index + 1}`);
|
|
823
|
+
}
|
|
824
|
+
const aliases = Array.isArray(value.aliases)
|
|
825
|
+
? value.aliases.map(string).filter(Boolean).slice(0, 8)
|
|
826
|
+
: [];
|
|
827
|
+
for (const alias of aliases) {
|
|
828
|
+
if (alias.length > 40) throw new Error(`wakeword_alias_invalid:${index + 1}`);
|
|
829
|
+
}
|
|
830
|
+
const sensitivity = Number(value.sensitivity);
|
|
831
|
+
return {
|
|
832
|
+
id,
|
|
833
|
+
text,
|
|
834
|
+
aliases,
|
|
835
|
+
enabled: value.enabled !== false,
|
|
836
|
+
sensitivity: Number.isFinite(sensitivity)
|
|
837
|
+
? Math.max(0, Math.min(1, Math.round(sensitivity * 1000) / 1000))
|
|
838
|
+
: 0.5,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export function normalizeWakewordBinding(value, index = 0) {
|
|
843
|
+
const phraseId = normalizeWakewordIdentifier(
|
|
844
|
+
value?.phrase_id || value?.phraseId || value?.id || "*",
|
|
845
|
+
"*",
|
|
846
|
+
{ wildcard: true },
|
|
847
|
+
);
|
|
848
|
+
const action = string(value?.action || "app.event")
|
|
849
|
+
.replace(/-/gu, "_")
|
|
850
|
+
.toLowerCase()
|
|
851
|
+
.slice(0, 80);
|
|
852
|
+
if (
|
|
853
|
+
!new Set([
|
|
854
|
+
"app.event",
|
|
855
|
+
"music.play",
|
|
856
|
+
"music.pause",
|
|
857
|
+
"music.toggle",
|
|
858
|
+
"music.next",
|
|
859
|
+
"music.previous",
|
|
860
|
+
"feedback.beep",
|
|
861
|
+
"light.clear",
|
|
862
|
+
]).has(action) &&
|
|
863
|
+
!action.startsWith("feedback.signal:")
|
|
864
|
+
) {
|
|
865
|
+
throw new Error(`wakeword_action_unsupported:${index + 1}`);
|
|
866
|
+
}
|
|
867
|
+
const feedback = Array.isArray(value.feedback)
|
|
868
|
+
? value.feedback.map(string).filter(Boolean).slice(0, 8)
|
|
869
|
+
: [];
|
|
870
|
+
return {
|
|
871
|
+
phrase_id: phraseId,
|
|
872
|
+
action,
|
|
873
|
+
target: string(value?.target).slice(0, 120),
|
|
874
|
+
feedback,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function normalizeWakewordIdentifier(value, fallback = "", options = {}) {
|
|
879
|
+
if (options.wildcard && string(value || fallback) === "*") return "*";
|
|
880
|
+
const text = string(value || fallback)
|
|
881
|
+
.toLowerCase()
|
|
882
|
+
.replace(/[^a-z0-9_.:-]+/gu, "_")
|
|
883
|
+
.replace(/^_+|_+$/gu, "")
|
|
884
|
+
.slice(0, 80);
|
|
885
|
+
if (!/^[a-z0-9][a-z0-9_.:-]{0,79}$/u.test(text)) {
|
|
886
|
+
throw new Error("wakeword_identifier_invalid");
|
|
887
|
+
}
|
|
888
|
+
return text;
|
|
889
|
+
}
|
|
890
|
+
|
|
722
891
|
export async function readFirmwareIndex(source = DEFAULT_FIRMWARE_MANIFEST_URL, options = {}) {
|
|
723
892
|
const location = string(source) || DEFAULT_FIRMWARE_MANIFEST_URL;
|
|
724
893
|
let payload;
|
|
@@ -993,6 +1162,10 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
993
1162
|
"audio.output.write",
|
|
994
1163
|
"audio.tts",
|
|
995
1164
|
"voice.wakeword",
|
|
1165
|
+
"voice.wakeword.configure",
|
|
1166
|
+
"voice.wakeword.bind",
|
|
1167
|
+
"voice.wakeword.trigger",
|
|
1168
|
+
"voice.event.bind",
|
|
996
1169
|
"voice.capture",
|
|
997
1170
|
"input.touch.read",
|
|
998
1171
|
"input.button.bind",
|
|
@@ -1028,6 +1201,7 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
1028
1201
|
if (wasmBytes < 1 || wasmBytes > wasmLimit) {
|
|
1029
1202
|
throw new Error("app_wasm_size_invalid");
|
|
1030
1203
|
}
|
|
1204
|
+
const wakeword = normalizeAppWakewordSpec(manifest);
|
|
1031
1205
|
return {
|
|
1032
1206
|
schema: "weclawbot.app.v1",
|
|
1033
1207
|
app_id: string(manifest.app_id),
|
|
@@ -1063,12 +1237,30 @@ export function normalizeAppManifest(manifest, artifact = {}) {
|
|
|
1063
1237
|
persist_on_reboot: manifest.install?.persist_on_reboot !== false,
|
|
1064
1238
|
source: "audioctl",
|
|
1065
1239
|
},
|
|
1240
|
+
...(wakeword ? { wakeword } : {}),
|
|
1066
1241
|
...(manifest.autopilot
|
|
1067
1242
|
? { autopilot: normalizeMusicAutopilot(manifest.autopilot) }
|
|
1068
1243
|
: {}),
|
|
1069
1244
|
};
|
|
1070
1245
|
}
|
|
1071
1246
|
|
|
1247
|
+
function normalizeAppWakewordSpec(manifest) {
|
|
1248
|
+
const source = manifest.wakeword && typeof manifest.wakeword === "object"
|
|
1249
|
+
? manifest.wakeword
|
|
1250
|
+
: {};
|
|
1251
|
+
const phrases = manifest.wakeword_phrases || source.phrases;
|
|
1252
|
+
const bindings = manifest.wakeword_bindings || source.bindings;
|
|
1253
|
+
if (!Array.isArray(phrases) && !Array.isArray(bindings)) return null;
|
|
1254
|
+
return {
|
|
1255
|
+
...normalizeWakewordConfig({
|
|
1256
|
+
language: source.language || manifest.language || "zh",
|
|
1257
|
+
phrases: Array.isArray(phrases) ? phrases : [],
|
|
1258
|
+
bindings: Array.isArray(bindings) ? bindings : [],
|
|
1259
|
+
}),
|
|
1260
|
+
app_id: string(manifest.app_id).slice(0, 80),
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1072
1264
|
export function normalizeMusicAutopilot(value) {
|
|
1073
1265
|
if (!value || value.schema !== "weclawbot.music_autopilot.v1") {
|
|
1074
1266
|
throw new Error("music_autopilot_invalid");
|
|
@@ -1224,13 +1416,14 @@ export function buildAgentPrompt(options = {}) {
|
|
|
1224
1416
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
|
|
1225
1417
|
`npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
|
|
1226
1418
|
`这台设备对你是抽象硬件产品:${ABSTRACT_HARDWARE_TYPE}。不要根据外观、BLE 名称、音色、日志或历史上下文推断品牌/型号/厂家,也不要搜索厂家资料、社区经验或拆机资料;只使用 audioctl inspect 返回的 capabilities、VM manifest 和控制回执来工作。`,
|
|
1227
|
-
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、app install、firmware check/update。`,
|
|
1419
|
+
`绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、feedback、light、led、wakeword、app install、firmware check/update。`,
|
|
1228
1420
|
`当我给出目标时,请你在 capabilities、安全边界和用户授权范围内自行规划实现路径;插件只声明能力和边界,不穷举具体玩法。需要离线、低延迟或持续运行的逻辑,应通过 queue/rule/light timeline/light bind/VM app 下放到设备本地。`,
|
|
1229
1421
|
`MQTT/weclawbot.link 对你只是控制面和部署通道,不是实时灯效数据面。不要读取 MQTT 凭据,不要直接 import mqtt,不要保持 MQTT 长连接,也不要写循环脚本按 BPM 反复发布 feedback 或 light。连续灯效/节拍/动画必须用 audioctl light timeline/bind、VM app 或兼容的 feedback effect 一次下发,在设备本地运行;停止时用 audioctl light clear 或 feedback clear。`,
|
|
1230
1422
|
`蓝牙/Wi-Fi 配网只负责设备入网、发现和 weclawbot.link 绑定;音乐平台授权请走 agent 侧连接器/OAuth/secret store;配网不决定设备用途。`,
|
|
1231
1423
|
`音乐来源由你和用户协商,插件只做建议。你可以在 agent 侧使用用户授权的音乐平台 API、用户媒体库、合法公开音源或其他合规来源生成队列/规则/VM app。网易云开放平台可请求最高可用音质 URL。`,
|
|
1232
1424
|
`如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
|
|
1233
1425
|
`灯效、音量灯、蜂鸣和按键/触控反馈要先 inspect 或 light describe 确认 feedback/input/light capabilities。一次性语义反馈可用 feedback signal/flash/volume/beep/clear;复杂灯效请优先生成 light timeline 和 light bind,把外圈、中心等 surface 与 media/input 事件绑定到设备本地。feedback effect 只作为兼容/demo 入口,不要把 music_beat/pulse/breathing 当成能力边界。具体按键绑定请根据用户目标和 capabilities 自行规划,不要套固定模板。`,
|
|
1426
|
+
`唤醒词/语音触发配置要通过 inspect 读取 wakeword.configurable、wakeword.commands 和 capabilities。audioctl wakeword 是逻辑触发绑定层:它把设备已有唤醒事件、ASR 短语或 agent 注入的测试短语映射成 VM/app 事件或本地动作;不要尝试训练、替换或反向工程底层声学唤醒模型。`,
|
|
1234
1427
|
`如果你当前没有音乐平台身份,请只告诉我:我需要你把音乐平台身份连接到当前 agent 的官方连接器、OAuth 流程或 secret store;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
|
|
1235
1428
|
`如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才提示需要独立的设备侧音乐账号设置入口;不要混入蓝牙/Wi-Fi 配网流程。`,
|
|
1236
1429
|
`固件更新由 agent 端发现新版、用户确认后执行:先运行 audioctl firmware check --json,只展示官网来源、当前版本、新版本和 release notes;只有用户明确同意升级后,才运行 audioctl firmware update --yes --json。固件只从 weclawbot.link 官方 manifest 下载,更新设备 host/runtime,不等同于 VM app,也不要用固件更新去实现普通应用需求。`,
|
|
@@ -1261,6 +1454,12 @@ export function summarizeInspect(inspect) {
|
|
|
1261
1454
|
const bluetooth = inspect?.bluetooth || {};
|
|
1262
1455
|
const ble = bluetooth.ble || {};
|
|
1263
1456
|
const wakeword = inspect?.wakeword || {};
|
|
1457
|
+
const wakewordPhraseCount = Array.isArray(wakeword.configured_phrases)
|
|
1458
|
+
? wakeword.configured_phrases.length
|
|
1459
|
+
: Number(wakeword.configured_phrase_count) || 0;
|
|
1460
|
+
const wakewordBindingCount = Array.isArray(wakeword.bindings)
|
|
1461
|
+
? wakeword.bindings.length
|
|
1462
|
+
: Number(wakeword.binding_count) || 0;
|
|
1264
1463
|
const playback = summarizePlayback(inspect || {});
|
|
1265
1464
|
const vm = inspect?.vm || {};
|
|
1266
1465
|
const health = inspect?.health || {};
|
|
@@ -1276,7 +1475,7 @@ export function summarizeInspect(inspect) {
|
|
|
1276
1475
|
`${bluetooth.service?.active || "unknown"} ${string(ble.name || "-")} ${string(ble.state || "-")}`
|
|
1277
1476
|
.trim(),
|
|
1278
1477
|
wakeword:
|
|
1279
|
-
`${wakeword.available ? "available" : "unavailable"}
|
|
1478
|
+
`${wakeword.available ? "available" : "unavailable"} configurable=${wakeword.configurable === true} phrases=${wakewordPhraseCount} bindings=${wakewordBindingCount} wake_runtime=${wakeword.services?.wake_runtime?.active || "unknown"} voice_runtime=${wakeword.services?.voice_runtime?.active || "unknown"}`
|
|
1280
1479
|
.trim(),
|
|
1281
1480
|
playback:
|
|
1282
1481
|
`${playback.desired}, volume ${playback.volume ?? "-"}, ${playback.track || "no track"}`,
|