@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 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.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.1.tar",
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"} ${Array.isArray(wakeword.wake_words) && wakeword.wake_words.length ? wakeword.wake_words.join("/") : "-"} wake_runtime=${wakeword.services?.wake_runtime?.active || "unknown"} voice_runtime=${wakeword.services?.voice_runtime?.active || "unknown"}`
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"}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",