@openbrt/audioctl 0.1.2 → 0.1.4

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
@@ -1,19 +1,36 @@
1
1
  # @openbrt/audioctl
2
2
 
3
- Generic WeClawBot audio-output control CLI and AI-agent plugin.
3
+ Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.
4
4
 
5
5
  It is meant for user-owned agents such as Codex, Claude Code, OpenClaw, Hermes,
6
6
  WorkBuddy, Gemini CLI, or any shell-capable agent runtime. It is not tied to one
7
- speaker brand. Any ESP32/Linux audio device that implements the WeClawBot MQTT
8
- audio command envelope can use the same package.
7
+ speaker brand. Any ESP32/Linux audio or voice endpoint that implements the
8
+ WeClawBot MQTT/app envelope can use the same package. Music playback is one app
9
+ on top of the endpoint; the same VM/capability model can later host TTS, voice
10
+ remote control, smart-home control, and voice interaction apps.
9
11
 
10
12
  ## Tell your agent
11
13
 
12
14
  ```text
13
- Install npm package @openbrt/audioctl, bind my audio device with pairing code 123456,
14
- run doctor, then use it as my default BGM/music output device.
15
+ 帮我在音箱建立一个工作 BGM 歌单,持续播放。请使用 @openbrt/audioctl 最新版。
15
16
  ```
16
17
 
18
+ Agent contract:
19
+
20
+ - If already bound, inspect the device and run the high-level task directly.
21
+ - If not bound, ask the user only for the short binding code, then bind and continue.
22
+ - Preferred BYOA flow: use a legal music platform API in the agent environment
23
+ to build a playable queue, then push it with `audioctl queue QUEUE.json --play --json`.
24
+ NetEase Cloud Music Open Platform can be used this way to request the highest
25
+ available quality URLs. Spotify should be driven through official Spotify
26
+ Connect/Web Playback SDK or existing-user playback control; do not assume the
27
+ Spotify Web API gives full-track direct audio URLs.
28
+ - If music-platform authorization is missing, guide the user through the
29
+ platform authorization entry available to the agent/phone, then continue after
30
+ the user says “已授权”.
31
+ - Never ask the user to paste AppSecret, PrivateKey, accessToken, refreshToken,
32
+ Wi-Fi passwords, MQTT credentials, or other secrets into chat.
33
+
17
34
  ## Install and bind
18
35
 
19
36
  ```bash
@@ -44,6 +61,7 @@ audioctl volumeup
44
61
  audioctl volumedown
45
62
  audioctl status
46
63
  audioctl inspect --json
64
+ audioctl queue ./work-bgm.queue.json --play
47
65
  audioctl workbgm --play
48
66
  audioctl rule ./my-playlist-rule.json
49
67
  audioctl deepnight
@@ -55,15 +73,24 @@ existing MQTT channel. Agent scripts can read Wi-Fi connection state, BLE
55
73
  provisioning state, wake-word capability, playback, VM/app status, and health
56
74
  without receiving Wi-Fi passwords, MQTT credentials, or music-platform secrets.
57
75
 
76
+ `queue --play` is the generic agent-built music path. The agent uses the user's
77
+ authorized music provider outside the speaker, writes a
78
+ `weclawbot.playable_queue.v1`/`schemaVersion: 1` queue containing only safe media
79
+ URLs and redacted metadata, then pushes it to the device. The queue must not
80
+ contain platform private keys, OAuth tokens, cookies, custom request headers, or
81
+ Wi-Fi/MQTT credentials. Expiring media URLs are allowed; refreshing them is the
82
+ agent/app's job.
83
+
58
84
  `workbgm --play` installs a persistent `work-bgm-autopilot` VM app on the audio
59
85
  device, writes the bundled 工作 BGM playlist rule through the device-side music
60
86
  adapter, requests a refresh, and starts playback once. The app expresses a
61
87
  continuous BGM intent and respects later user-initiated pause/resume controls.
62
- If the device reports `netease_auth_refresh_failed`, the agent should stop
63
- retrying and tell the user to complete the NetEase Music authorization flow in a
64
- trusted device/provisioning or maintenance environment. The agent must not ask
65
- the user to paste AppSecret, PrivateKey, accessToken, refreshToken, Wi-Fi
66
- passwords, or MQTT credentials into chat.
88
+ If the device reports `netease_auth_refresh_failed`, that only means the legacy
89
+ device-side NetEase runtime cannot refresh itself. The better agent behavior is
90
+ to switch to the BYOA queue/app path: obtain the user's music-platform identity
91
+ in the agent environment, build a fresh queue, and push it with `audioctl queue`.
92
+ Only if the user explicitly wants the device-side NetEase runtime should the
93
+ agent route them to the phone authorization entry.
67
94
 
68
95
  `deepnight` pushes the bundled “深夜工作” playlist rule and asks the device to
69
96
  refresh/play according to its local runtime. The package never contains music
package/agent-plugin.json CHANGED
@@ -4,10 +4,14 @@
4
4
  "name": "audioctl",
5
5
  "package": "@openbrt/audioctl",
6
6
  "bin": "audioctl",
7
- "description": "Bind and control WeClawBot-compatible audio output devices from user-owned AI agents.",
7
+ "description": "Bind and drive WeClawBot-compatible audio, voice, and ambient I/O devices from user-owned AI agents.",
8
8
  "transport": "weclawbot.link/mqtt",
9
+ "user_contract": "用户只需要给出设备身份和目标;agent 负责选择合法平台 API、完成授权引导、构建最小权限脚本/队列、推送到设备 VM、诊断和报告结果。",
9
10
  "device_classes": [
11
+ "audio_io",
10
12
  "audio_output",
13
+ "voice_endpoint",
14
+ "smart_home_endpoint",
11
15
  "music_player"
12
16
  ],
13
17
  "agents": [
@@ -27,9 +31,15 @@
27
31
  "audio.previous",
28
32
  "audio.seek",
29
33
  "audio.set_volume",
34
+ "audio.queue.write",
35
+ "audio.tts",
36
+ "voice.wakeword",
37
+ "voice.capture",
38
+ "iot.control",
30
39
  "device.inspect",
31
40
  "music.autopilot",
32
41
  "music.set_rule",
42
+ "music.set_queue",
33
43
  "music.work_bgm",
34
44
  "music.status",
35
45
  "app.install"
@@ -40,6 +50,23 @@
40
50
  "credentials_env": "WEC_AUDIO_CREDENTIALS",
41
51
  "code_pattern": "^[A-Za-z0-9_-]{6,64}$"
42
52
  },
53
+ "natural_language_tasks": [
54
+ {
55
+ "intent": "帮我在音箱建立一个工作 BGM 歌单,持续播放",
56
+ "primary_command": "audioctl queue QUEUE.json --play --json",
57
+ "agent_strategy": "先运行 audioctl inspect --json。优先在 agent 侧使用用户授权的音乐平台 API 生成 QUEUE.json,再运行 audioctl queue QUEUE.json --play --json;网易云开放平台可生成最高可用音质播放 URL。Spotify 必须走官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 设备,不能假设 Web API 能提供完整歌曲直链。如果 agent 当前没有音乐平台身份,只要求用户在 agent/手机授权入口完成对应平台授权。",
58
+ "fallback_command": "audioctl workbgm --play --json",
59
+ "if_unbound": "只向用户索要绑定码,然后运行 audioctl bind CODE --name AGENT;不要要求用户理解 MQTT、topic 或凭据文件。",
60
+ "if_music_platform_auth_missing": "告诉用户:我需要你给这个 agent 一个音乐平台身份。请在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权;完成后只需回复“已授权”。不要把 AppSecret、PrivateKey、accessToken、refreshToken、Wi-Fi 密码或 MQTT 凭据粘贴到聊天里。",
61
+ "if_device_side_netease_auth_failed": "这是设备内置网易运行时的旧授权失效。优先不要让用户折腾原厂 App;改由 agent 侧使用用户授权的音乐平台 API 生成队列并推送。只有当用户明确要继续使用设备侧网易运行时时,才引导手机端重新授权。",
62
+ "after_user_confirms_authorized": "agent 使用已授权的平台 API 生成 QUEUE.json,运行 audioctl queue QUEUE.json --play --json,然后报告是否 active/degraded。"
63
+ },
64
+ {
65
+ "intent": "让音箱作为语音遥控、家庭智能或语音互动端点",
66
+ "primary_command": "audioctl app install MANIFEST.json APP.wasm",
67
+ "agent_strategy": "不要把设备假定为音乐播放器。先运行 audioctl inspect --json 读取设备能力,再生成声明最小 capabilities 的 VM app。语音、TTS、家居控制等能力必须通过 manifest capabilities 显式声明;设备侧只执行授权范围内的 I/O。"
68
+ }
69
+ ],
43
70
  "commands": [
44
71
  {
45
72
  "name": "bind",
@@ -65,6 +92,10 @@
65
92
  "name": "rule",
66
93
  "usage": "audioctl rule RULE.json"
67
94
  },
95
+ {
96
+ "name": "queue",
97
+ "usage": "audioctl queue QUEUE.json [--play]"
98
+ },
68
99
  {
69
100
  "name": "app install",
70
101
  "usage": "audioctl app install MANIFEST.json APP.wasm"
package/bin/audioctl.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  buildAppInstallControl,
14
14
  buildAgentPrompt,
15
15
  buildAudioControl,
16
+ buildQueueControl,
16
17
  buildWorkBgmAppInstallControl,
17
18
  expandPath,
18
19
  normalizeCredentials,
@@ -45,6 +46,7 @@ async function run(name, values) {
45
46
  if (name === "prompt") return commandPrompt(values);
46
47
  if (name === "manifest") return commandManifest();
47
48
  if (name === "app") return commandApp(values);
49
+ if (name === "queue") return commandQueue(values);
48
50
  if (name === "rule") return commandRule(values);
49
51
  if (name === "workbgm") return commandWorkBgm(values);
50
52
  if (name === "deepnight") return commandDeepnight(values);
@@ -63,6 +65,7 @@ function usage(exitCode = 0) {
63
65
  ${COMMAND_NAME} volume 0..100
64
66
  ${COMMAND_NAME} seek MILLISECONDS
65
67
  ${COMMAND_NAME} rule RULE.json
68
+ ${COMMAND_NAME} queue QUEUE.json [--play]
66
69
  ${COMMAND_NAME} app install MANIFEST.json APP.wasm
67
70
  ${COMMAND_NAME} workbgm [--play]
68
71
  ${COMMAND_NAME} deepnight
@@ -202,6 +205,30 @@ async function commandRule(values) {
202
205
  await send("set_rule", options, { rule });
203
206
  }
204
207
 
208
+ async function commandQueue(values) {
209
+ const options = parseOptions(values, {
210
+ credentials: credentialsPath(),
211
+ timeout: 12,
212
+ json: false,
213
+ nowait: false,
214
+ play: false,
215
+ });
216
+ const file = String(options._[0] || "");
217
+ if (!file) throw new Error("queue_requires_file");
218
+ const queue = JSON.parse(await fs.readFile(file, "utf8"));
219
+ const credentials = await readCredentials(options.credentials);
220
+ const delivery = await publishAudioControl(
221
+ credentials,
222
+ buildQueueControl({ queue, play: options.play }),
223
+ {
224
+ timeoutMs: Number(options.timeout) * 1000,
225
+ wait: !options.nowait,
226
+ },
227
+ );
228
+ print(delivery.status || delivery, options.json);
229
+ if (delivery.status?.kind === "rejected") process.exitCode = 1;
230
+ }
231
+
205
232
  async function commandApp(values) {
206
233
  const subcommand = String(values[0] || "");
207
234
  if (subcommand !== "install") {
package/lib/index.mjs CHANGED
@@ -45,6 +45,8 @@ export const WORKBGM_RULE = Object.freeze({
45
45
  refreshHintSeconds: 21600,
46
46
  });
47
47
 
48
+ export const PLAYABLE_QUEUE_SCHEMA = "weclawbot.playable_queue.v1";
49
+
48
50
  const WORKBGM_APP_WASM_BASE64 =
49
51
  "AGFzbQEAAAABBAFgAAADAgEABwgBBG1haW4AAAoEAQIACw==";
50
52
 
@@ -174,6 +176,15 @@ export function buildAudioControl(command, options = {}) {
174
176
  };
175
177
  }
176
178
 
179
+ export function buildQueueControl(options = {}) {
180
+ return buildAudioControl("set_queue", {
181
+ id: options.id,
182
+ queue: options.queue,
183
+ play: options.play,
184
+ autoplay: options.autoplay,
185
+ });
186
+ }
187
+
177
188
  export function buildAppInstallControl(options = {}) {
178
189
  const wasm =
179
190
  Buffer.isBuffer(options.wasm) || options.wasm instanceof Uint8Array
@@ -270,6 +281,7 @@ export function normalizeAudioCommand(command, options = {}) {
270
281
  "reload",
271
282
  "status",
272
283
  "set_rule",
284
+ "set_queue",
273
285
  ]);
274
286
  if (!supported.has(normalized)) throw new Error(`command_unsupported: ${command}`);
275
287
 
@@ -291,9 +303,79 @@ export function normalizeAudioCommand(command, options = {}) {
291
303
  if (normalized === "set_rule") {
292
304
  music.rule = validateRule(options.rule);
293
305
  }
306
+ if (normalized === "set_queue") {
307
+ music.queue = validatePlayableQueue(options.queue);
308
+ music.play = options.play === true || options.autoplay === true;
309
+ }
294
310
  return music;
295
311
  }
296
312
 
313
+ export function validatePlayableQueue(queue) {
314
+ if (!queue || typeof queue !== "object") {
315
+ throw new Error("queue_invalid");
316
+ }
317
+ const schemaOk =
318
+ queue.schema === PLAYABLE_QUEUE_SCHEMA ||
319
+ queue.schemaVersion === 1;
320
+ if (!schemaOk || !Array.isArray(queue.tracks) || queue.tracks.length < 1) {
321
+ throw new Error("queue_invalid");
322
+ }
323
+ if (queue.tracks.length > 80) {
324
+ throw new Error("queue_too_large");
325
+ }
326
+ const normalizedTracks = queue.tracks.map((track, index) => {
327
+ if (!track || typeof track !== "object") {
328
+ throw new Error(`queue_track_invalid:${index + 1}`);
329
+ }
330
+ const forbiddenKeys = [
331
+ "headers",
332
+ "cookies",
333
+ "cookie",
334
+ "authorization",
335
+ "accessToken",
336
+ "access_token",
337
+ "refreshToken",
338
+ "refresh_token",
339
+ "privateKey",
340
+ "private_key",
341
+ "appSecret",
342
+ "app_secret",
343
+ ];
344
+ for (const key of forbiddenKeys) {
345
+ if (Object.hasOwn(track, key)) {
346
+ throw new Error(`queue_track_secret_field_blocked:${key}`);
347
+ }
348
+ }
349
+ const url = normalizeMediaUrl(track.url, index + 1);
350
+ return {
351
+ title: string(track.title || `Track ${index + 1}`).slice(0, 160),
352
+ artist: string(track.artist).slice(0, 160),
353
+ originalId: string(track.originalId || track.original_id || track.id || index + 1).slice(0, 120),
354
+ encryptedId: string(track.encryptedId || track.encrypted_id).slice(0, 160),
355
+ provider: string(track.provider || queue.provider || "agent").slice(0, 64),
356
+ durationMs: clamp(Number(track.durationMs || track.duration_ms) || 0, 0, 24 * 60 * 60 * 1000),
357
+ bitrate: clamp(Number(track.bitrate) || 0, 0, 999_999),
358
+ format: string(track.format).slice(0, 32),
359
+ url,
360
+ };
361
+ });
362
+ return {
363
+ schema: PLAYABLE_QUEUE_SCHEMA,
364
+ schemaVersion: 1,
365
+ title: string(queue.title || "Agent playlist").slice(0, 120),
366
+ source: string(queue.source || "agent-built playable queue").slice(0, 240),
367
+ provider: string(queue.provider || "agent").slice(0, 64),
368
+ generatedBy: string(queue.generatedBy || queue.generated_by || "audioctl-agent").slice(0, 120),
369
+ generatedAt: string(queue.generatedAt || queue.generated_at) || new Date().toISOString(),
370
+ expiresAt: string(queue.expiresAt || queue.expires_at).slice(0, 80),
371
+ expiresAtEpoch: Math.max(0, Math.floor(Number(queue.expiresAtEpoch || queue.expires_at_epoch) || 0)),
372
+ refreshHintSeconds: Math.max(0, Math.floor(Number(queue.refreshHintSeconds || queue.refresh_hint_seconds) || 0)),
373
+ loop: queue.loop !== false,
374
+ respectUserPause: queue.respectUserPause !== false && queue.respect_user_pause !== false,
375
+ tracks: normalizedTracks,
376
+ };
377
+ }
378
+
297
379
  export function validateRule(rule) {
298
380
  if (
299
381
  !rule ||
@@ -339,7 +421,15 @@ export function normalizeAppManifest(manifest, artifact = {}) {
339
421
  "log.emit",
340
422
  "music.state.read",
341
423
  "music.rule.write",
424
+ "music.queue.write",
342
425
  "music.playback.write",
426
+ "audio.queue.write",
427
+ "audio.playback.write",
428
+ "audio.output.write",
429
+ "audio.tts",
430
+ "voice.wakeword",
431
+ "voice.capture",
432
+ "iot.control",
343
433
  ]);
344
434
  const capabilities = Array.isArray(manifest.capabilities)
345
435
  ? manifest.capabilities.map((capability) => string(capability)).filter(Boolean)
@@ -463,12 +553,17 @@ export function remediationForStatus(status) {
463
553
  user_action_required: true,
464
554
  safe_to_retry_without_user: false,
465
555
  summary:
466
- "设备内的网易云用户授权已过期,refresh token 没有续上;agent 不能凭空恢复用户授权。",
556
+ "设备内置网易运行时的用户授权已过期。优先改走 BYOA:由 agent 使用用户授权的音乐平台 API 生成播放队列并推送到设备。",
557
+ user_message:
558
+ "我需要一个可用的音乐平台身份来生成 BGM 队列。请在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权;完成后回到 agent 说“已授权”。不要发送任何密钥、token 或密码。",
559
+ agent_after_user_confirms:
560
+ "generate QUEUE.json with the authorized music platform API, then run audioctl queue QUEUE.json --play --json",
467
561
  steps: [
468
- "请用户在可信的维护环境或设备配网/授权界面重新完成网易云音乐开放平台用户授权。",
469
- "授权完成后,通过设备的安全授权导入流程把新的 OpenAPI session 写回音箱。",
470
- "不要把 AppSecret、PrivateKey、accessToken、refreshToken Wi-Fi 密码粘贴到聊天窗口。",
471
- "授权恢复后重新运行 audioctl doctor --online,再执行 audioctl workbgm --play。",
562
+ "优先使用 agent 侧 BYOA 音乐驱动:让用户在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权。",
563
+ "网易云开放平台可由 agent 请求最高可用音质 URL,生成 weclawbot.playable_queue.v1 队列,然后运行 audioctl queue QUEUE.json --play --json。",
564
+ "Spotify 必须走官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 设备;不要声称 Spotify Web API 能提供完整歌曲直链。",
565
+ "不要要求用户理解内部授权包,也不要让用户粘贴 AppSecret、PrivateKey、accessToken、refreshToken、Wi-Fi 密码或 MQTT 凭据。",
566
+ "只有用户明确要求继续使用设备侧网易运行时时,才引导用户去手机端配网/授权入口重新授权,然后重试 audioctl workbgm --play --json。",
472
567
  ],
473
568
  });
474
569
  }
@@ -500,18 +595,20 @@ export function buildAgentPrompt(options = {}) {
500
595
  const expires = Number(options.expiresAt || options.expires_at || 0);
501
596
  const expiresLine = expires > 0 ? `绑定码过期时间:${new Date(expires).toISOString()}` : "";
502
597
  return [
503
- `请作为我的 AI agent 绑定并控制一台 WeClawBot 音频输出设备。`,
598
+ `请作为我的 AI agent 绑定并控制一台 WeClawBot 音频/语音/环境 I/O 设备。`,
504
599
  `设备别名:${alias}`,
505
600
  `绑定码:${code}`,
506
601
  expiresLine,
507
602
  `请在你的运行环境安装 npm 包 ${PACKAGE_NAME},然后执行:`,
508
603
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} bind ${code} --name ${shellToken(agentName)}`,
509
604
  `npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} doctor --online`,
510
- `绑定成功后,把它作为默认 BGM/音乐输出设备。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、rule、workbgm。`,
511
- `如果我要建立“工作 BGM 歌单,持续播放”,请优先执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} workbgm --play --json`,
512
- `如果返回 health.problems 包含 netease_auth_refresh_failed,请明确告诉我:需要用户重新完成网易云音乐授权;不要反复重试,也不要要求我把 AppSecret、PrivateKey、accessToken refreshToken 发到聊天里。`,
605
+ `绑定成功后,把它作为默认音频/语音/环境 I/O 端点;不要把它假定成只会播放音乐。可用命令包括 play、pause、toggle、next、previous、volume、volumeup、volumedown、status、inspect、queue、rule、workbgm、app install。`,
606
+ `如果我要建立“工作 BGM 歌单,持续播放”,优先由你在 agent 侧使用用户授权的音乐平台 API 生成 QUEUE.json,再执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} queue QUEUE.json --play --json。网易云开放平台可请求最高可用音质 URL。`,
607
+ `如果用户选择 Spotify,必须走 Spotify 官方 Spotify Connect/Web Playback SDK 或控制用户已有 Spotify 播放设备;不要假设 Spotify Web API 能提供完整歌曲直链。`,
608
+ `如果你当前没有音乐平台身份,请只告诉我:我需要你在 agent 支持的网易云开放平台或 Spotify 官方授权入口完成授权;完成后回复“已授权”。不要反复重试,不要让我理解内部授权包,也不要要求我把 AppSecret、PrivateKey、accessToken 或 refreshToken 发到聊天里。`,
609
+ `如果返回 health.problems 包含 netease_auth_refresh_failed,说明设备侧内置网易运行时授权失效;优先改走 agent 侧 queue/app 驱动。只有我明确要求继续使用设备侧网易运行时时,才引导手机端重新授权并重试 workbgm。`,
513
610
  `如果我要播放「${profile}」,也可以执行:npm exec --package ${PACKAGE_NAME} -- ${COMMAND_NAME} deepnight`,
514
- `不要向设备写入音乐平台私钥;音乐平台授权、歌单刷新和长期规则由设备侧已配置的安全运行时处理。`,
611
+ `不要向设备写入全局音乐平台私钥;平台授权优先保存在 agent 运行环境。只有在我明确授权时,才把最小范围、可轮换、可撤销的 app-scoped 凭据随 VM app 写入设备。`,
515
612
  ].filter(Boolean).join("\n");
516
613
  }
517
614
 
@@ -563,6 +660,55 @@ export function summarizeInspect(inspect) {
563
660
  };
564
661
  }
565
662
 
663
+ function normalizeMediaUrl(value, trackNumber) {
664
+ const raw = string(value);
665
+ let parsed;
666
+ try {
667
+ parsed = new URL(raw);
668
+ } catch {
669
+ throw new Error(`queue_track_url_invalid:${trackNumber}`);
670
+ }
671
+ const protocol = parsed.protocol.toLowerCase();
672
+ const host = parsed.hostname.toLowerCase();
673
+ if (!new Set(["http:", "https:"]).has(protocol) || !host) {
674
+ throw new Error(`queue_track_url_invalid:${trackNumber}`);
675
+ }
676
+ if (!isPublicMediaHost(host)) {
677
+ throw new Error(`queue_track_url_host_blocked:${trackNumber}`);
678
+ }
679
+ if (protocol === "http:" && !isNeteaseMediaHost(host)) {
680
+ throw new Error(`queue_track_url_requires_https:${trackNumber}`);
681
+ }
682
+ return parsed.toString();
683
+ }
684
+
685
+ function isNeteaseMediaHost(host) {
686
+ return host === "music.126.net" || host.endsWith(".music.126.net");
687
+ }
688
+
689
+ function isPublicMediaHost(host) {
690
+ if (
691
+ host === "localhost" ||
692
+ host.endsWith(".localhost") ||
693
+ host.endsWith(".local") ||
694
+ host.endsWith(".lan")
695
+ ) {
696
+ return false;
697
+ }
698
+ const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u);
699
+ if (!match) return true;
700
+ const parts = match.slice(1).map((part) => Number(part));
701
+ if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
702
+ return false;
703
+ }
704
+ const [a, b] = parts;
705
+ if (a === 0 || a === 10 || a === 127 || (a === 169 && b === 254)) return false;
706
+ if (a === 172 && b >= 16 && b <= 31) return false;
707
+ if (a === 192 && b === 168) return false;
708
+ if (a >= 224) return false;
709
+ return true;
710
+ }
711
+
566
712
  function connectMqtt(profile, options = {}) {
567
713
  const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 12_000);
568
714
  return mqtt.connect(profile.url, {
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "@openbrt/audioctl",
3
- "version": "0.1.2",
4
- "description": "Generic WeClawBot audio-output control CLI and AI-agent plugin.",
3
+ "version": "0.1.4",
4
+ "description": "Generic WeClawBot audio/voice endpoint control CLI and AI-agent plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "weclawbot",
9
9
  "audio",
10
+ "voice",
10
11
  "music",
12
+ "tts",
13
+ "smart-home",
11
14
  "mqtt",
12
15
  "esp32",
16
+ "audio-vm",
13
17
  "agent",
14
18
  "byoa",
15
19
  "openclaw",