@khalido/home 0.2.1 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.2] - 2026-11-12
8
+
9
+ ### Added
10
+
11
+ - **cast:** WAV speech. `announce` rewrites a clip of WAV bytes as 48 kHz, 16-bit, stereo PCM before serving it, in plain TypeScript, so a bot's TTS WAV needs no ffmpeg; a dry run says so. A 24 kHz mono WAV failed on audio-only Nest speakers (2026-11-09); rewritten, it played on a Nest Audio, a Nest Mini, a Google Home speaker, a Nest Hub and a Nest Hub Max (2026-11-12). `castWav`, `CAST_WAV` and the `WavFormat` type are exported for a clip served by URL; a clip given as a URL is sent as it is.
12
+ - **cast, spotify:** dry run. Every Cast and Spotify write takes `{ dryRun: true }`: it checks as a real write does, sends nothing, and says what it would send (`wouldSend`). Cast's `setVolume`, `setMuted` and `stop` now resolve `{ wouldSend }` under a dry run and `undefined` otherwise; a dry `announce` also names in `notRestored` the app it would interrupt, and serves nothing. Spotify's `duck` and `interrupt` run `fn` and say in `why` what they would have done. `home cast …` and `home spotify …` take `--dry-run`. New types: `CastRequest`, `CastDryRun`, `CastWriteOptions`, `SpotifyRequest`, `SpotifyWriteOptions`.
13
+
7
14
  ## [0.2.1] - 2026-11-12
8
15
 
9
16
  ### Added
@@ -151,7 +158,8 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
151
158
  - **Agent skills** in `skills/`: `use-home` (reading safely, connecting, the rules for writes) and `draw-home` (an SVG plan of a home and its rooms). `llms.txt` points agents at the docs.
152
159
  - **Node.** Hue and Enphase take the fetches in `@khalido/home/node`. The library runs on Bun 1.4+ and Node 24+; the `home` command needs Bun.
153
160
 
154
- [Unreleased]: https://github.com/khalido/home.ts/compare/v0.2.1...HEAD
161
+ [Unreleased]: https://github.com/khalido/home.ts/compare/v0.2.2...HEAD
162
+ [0.2.2]: https://github.com/khalido/home.ts/compare/v0.2.1...v0.2.2
155
163
  [0.2.1]: https://github.com/khalido/home.ts/compare/v0.2.0...v0.2.1
156
164
  [0.2.0]: https://github.com/khalido/home.ts/compare/v0.1.3...v0.2.0
157
165
  [0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
@@ -9,7 +9,8 @@ import { type CastActions, type ServeClip } from "./writes.ts";
9
9
  export type { CastConnect } from "./codec.ts";
10
10
  export { castDevices, castForm, castFree, castFreeSpeakers, castStatus, } from "./state.ts";
11
11
  export type { CastDevice, CastForm, CastSkipped, CastSnapshot, CastStatus, Resolved, } from "./state.ts";
12
- export type { AnnounceResult, CastActions, CastClip, ServeClip, } from "./writes.ts";
12
+ export { CAST_WAV, castWav, type WavFormat } from "./wav.ts";
13
+ export type { AnnounceResult, CastActions, CastClip, CastDryRun, CastRequest, CastWriteOptions, ServeClip, } from "./writes.ts";
13
14
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
14
15
  export declare const configSchema: z.ZodObject<{
15
16
  connector: z.ZodLiteral<"cast">;
@@ -9,6 +9,7 @@ import { CastPayload, KNOWN_TYPES } from "./schemas.js";
9
9
  import { castKey, connection, found, mediaApp, reduce, castDevices, castStatus, } from "./state.js";
10
10
  import { castActions, } from "./writes.js";
11
11
  export { castDevices, castForm, castFree, castFreeSpeakers, castStatus, } from "./state.js";
12
+ export { CAST_WAV, castWav } from "./wav.js";
12
13
  /** A Cast device or speaker group in a home config. No host: a group's moves with its leader, so every address comes from discovery. */
13
14
  export const configSchema = z.looseObject({
14
15
  connector: z.literal("cast"),
@@ -0,0 +1,31 @@
1
+ /** What a WAV file says about its audio. */
2
+ export type WavFormat = {
3
+ sampleRate: number;
4
+ channels: number;
5
+ bitsPerSample: number;
6
+ /** 1 integer PCM, 3 IEEE float; WAVE_FORMAT_EXTENSIBLE is read as its sub-format. */
7
+ encoding: "pcm" | "float";
8
+ };
9
+ /** The rate, depth and channels every WAV clip is rewritten to. */
10
+ export declare const CAST_WAV: Omit<WavFormat, "encoding">;
11
+ /** Whether a clip's MIME type is WAV. */
12
+ export declare const isWav: (type: string | undefined) => boolean;
13
+ /** A WAV file's format and its samples, each channel as floats −1 to 1. Throws on anything but integer or float PCM. */
14
+ export declare function readWav(bytes: Uint8Array): {
15
+ format: WavFormat;
16
+ channels: Float32Array[];
17
+ };
18
+ /**
19
+ * A WAV clip as one a Cast speaker plays: 48 kHz, 16-bit, stereo (`CAST_WAV`). A clip already in that form comes back as
20
+ * it is. Mono is copied to both channels; more than two channels keep the first two. A float sample past ±1 is limited to
21
+ * full scale, as any 16-bit output must.
22
+ */
23
+ export declare function castWav(bytes: Uint8Array): {
24
+ bytes: Uint8Array;
25
+ from: WavFormat;
26
+ converted: boolean;
27
+ };
28
+ /** A format in a few words: `24000 Hz mono 16-bit`. */
29
+ export declare const wavText: (f: Omit<WavFormat, "encoding"> & {
30
+ encoding?: string;
31
+ }) => string;
@@ -0,0 +1,149 @@
1
+ // WAV for Cast speakers. Google lists WAV (LPCM) for Google Home speakers without saying which rates
2
+ // (developers.google.com/cast/docs/media), and a 24 kHz mono WAV failed on two audio-only Nest speakers while the displays
3
+ // played it (live, 2026-11-09). So a WAV clip is rewritten as 48 kHz, 16-bit, stereo, the most common LPCM there is, in
4
+ // plain TypeScript: no encoder, nothing to install. Linear interpolation with no low-pass: enough for speech (TTS is 16 to
5
+ // 24 kHz); a source above 48 kHz would alias.
6
+ /** The rate, depth and channels every WAV clip is rewritten to. */
7
+ export const CAST_WAV = {
8
+ sampleRate: 48_000,
9
+ channels: 2,
10
+ bitsPerSample: 16,
11
+ };
12
+ const WAV_TYPES = new Set([
13
+ "audio/wav",
14
+ "audio/wave",
15
+ "audio/x-wav",
16
+ "audio/vnd.wave",
17
+ ]);
18
+ /** Whether a clip's MIME type is WAV. */
19
+ export const isWav = (type) => !!type && WAV_TYPES.has(type.split(";")[0].trim().toLowerCase());
20
+ const ascii = (b, at) => String.fromCharCode(b[at], b[at + 1], b[at + 2], b[at + 3]);
21
+ /** A WAV file's format and its samples, each channel as floats −1 to 1. Throws on anything but integer or float PCM. */
22
+ export function readWav(bytes) {
23
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
24
+ if (bytes.length < 12 ||
25
+ ascii(bytes, 0) !== "RIFF" ||
26
+ ascii(bytes, 8) !== "WAVE")
27
+ throw new Error("wav: not a RIFF WAVE file");
28
+ let format;
29
+ let blockAlign = 0;
30
+ let data;
31
+ for (let at = 12; at + 8 <= bytes.length;) {
32
+ const id = ascii(bytes, at);
33
+ const size = view.getUint32(at + 4, true);
34
+ // 0xFFFFFFFF is a streaming writer's "unknown length" (ffmpeg on a pipe): to the end. Otherwise a chunk cut short is
35
+ // a file cut short, and a cut-off announcement must not play as if it were whole.
36
+ const end = size === 0xffffffff ? bytes.length : at + 8 + size;
37
+ if (end > bytes.length && (id === "fmt " || id === "data"))
38
+ throw new Error(`wav: the ${id.trim()} chunk says ${size} bytes, the file has ${bytes.length - at - 8}`);
39
+ const body = bytes.subarray(at + 8, Math.min(end, bytes.length));
40
+ if (id === "fmt ") {
41
+ if (body.length < 16)
42
+ throw new Error("wav: a fmt chunk under 16 bytes");
43
+ const f = new DataView(body.buffer, body.byteOffset, body.length);
44
+ let tag = f.getUint16(0, true);
45
+ if (tag === 0xfffe && body.length >= 26)
46
+ tag = f.getUint16(24, true); // extensible: the sub-format GUID's first two bytes
47
+ if (tag !== 1 && tag !== 3)
48
+ throw new Error(`wav: format ${tag} is not PCM or float`);
49
+ format = {
50
+ channels: f.getUint16(2, true),
51
+ sampleRate: f.getUint32(4, true),
52
+ bitsPerSample: f.getUint16(14, true),
53
+ encoding: tag === 3 ? "float" : "pcm",
54
+ };
55
+ blockAlign = f.getUint16(12, true);
56
+ }
57
+ else if (id === "data")
58
+ data = body;
59
+ if (size === 0xffffffff)
60
+ break;
61
+ at += 8 + size + (size % 2); // chunks are padded to an even length
62
+ }
63
+ if (!format)
64
+ throw new Error("wav: no fmt chunk");
65
+ if (!data)
66
+ throw new Error("wav: no data chunk");
67
+ const { channels: n, bitsPerSample: bits, encoding } = format;
68
+ const width = bits / 8;
69
+ if (!n ||
70
+ !format.sampleRate ||
71
+ !(encoding === "float" ? bits === 32 : [8, 16, 24, 32].includes(bits)))
72
+ throw new Error(`wav: ${bits}-bit ${encoding}, ${n} channels at ${format.sampleRate} Hz is not read`);
73
+ if (blockAlign !== width * n)
74
+ throw new Error(`wav: ${blockAlign} bytes a frame, not ${width * n} for ${n} × ${bits}-bit`);
75
+ const frames = Math.floor(data.length / (width * n));
76
+ if (!frames)
77
+ throw new Error("wav: no samples");
78
+ const d = new DataView(data.buffer, data.byteOffset, data.length);
79
+ const out = Array.from({ length: n }, () => new Float32Array(frames));
80
+ for (let i = 0; i < frames; i++)
81
+ for (let c = 0; c < n; c++) {
82
+ const at = (i * n + c) * width;
83
+ out[c][i] =
84
+ encoding === "float"
85
+ ? d.getFloat32(at, true)
86
+ : bits === 8
87
+ ? (d.getUint8(at) - 128) / 128
88
+ : bits === 16
89
+ ? d.getInt16(at, true) / 32768
90
+ : bits === 24
91
+ ? ((d.getUint8(at + 2) << 24) |
92
+ (d.getUint8(at + 1) << 16) |
93
+ (d.getUint8(at) << 8)) /
94
+ 2147483648
95
+ : d.getInt32(at, true) / 2147483648;
96
+ }
97
+ return { format, channels: out };
98
+ }
99
+ /**
100
+ * A WAV clip as one a Cast speaker plays: 48 kHz, 16-bit, stereo (`CAST_WAV`). A clip already in that form comes back as
101
+ * it is. Mono is copied to both channels; more than two channels keep the first two. A float sample past ±1 is limited to
102
+ * full scale, as any 16-bit output must.
103
+ */
104
+ export function castWav(bytes) {
105
+ const { format, channels } = readWav(bytes);
106
+ const { sampleRate: rate, channels: n, bitsPerSample } = CAST_WAV;
107
+ if (format.encoding === "pcm" &&
108
+ format.sampleRate === rate &&
109
+ format.channels === n &&
110
+ format.bitsPerSample === bitsPerSample)
111
+ return { bytes, from: format, converted: false };
112
+ const src = [channels[0], channels[1] ?? channels[0]];
113
+ const frames = Math.round((src[0].length * rate) / format.sampleRate);
114
+ const step = format.sampleRate / rate;
115
+ const out = new Uint8Array(44 + frames * n * 2);
116
+ const v = new DataView(out.buffer);
117
+ const text = (at, s) => [...s].forEach((ch, i) => v.setUint8(at + i, ch.charCodeAt(0)));
118
+ text(0, "RIFF");
119
+ v.setUint32(4, out.length - 8, true);
120
+ text(8, "WAVE");
121
+ text(12, "fmt ");
122
+ v.setUint32(16, 16, true);
123
+ v.setUint16(20, 1, true); // PCM
124
+ v.setUint16(22, n, true);
125
+ v.setUint32(24, rate, true);
126
+ v.setUint32(28, rate * n * 2, true); // bytes per second
127
+ v.setUint16(32, n * 2, true); // bytes per frame
128
+ v.setUint16(34, 16, true);
129
+ text(36, "data");
130
+ v.setUint32(40, frames * n * 2, true);
131
+ for (let i = 0; i < frames; i++) {
132
+ const x = i * step;
133
+ const j = Math.floor(x);
134
+ const t = x - j;
135
+ for (let c = 0; c < n; c++) {
136
+ const s = src[c];
137
+ const a = s[j] ?? 0;
138
+ const b = s[j + 1] ?? a;
139
+ const raw = a + (b - a) * t;
140
+ if (Number.isNaN(raw))
141
+ throw new Error(`wav: sample ${j} is not a number`);
142
+ const y = Math.max(-1, Math.min(1, raw));
143
+ v.setInt16(44 + (i * n + c) * 2, Math.round(y * 32767), true);
144
+ }
145
+ }
146
+ return { bytes: out, from: format, converted: true };
147
+ }
148
+ /** A format in a few words: `24000 Hz mono 16-bit`. */
149
+ export const wavText = (f) => `${f.sampleRate} Hz ${f.channels === 1 ? "mono" : f.channels === 2 ? "stereo" : `${f.channels}-channel`} ${f.bitsPerSample}-bit${f.encoding === "float" ? " float" : ""}`;
@@ -16,7 +16,24 @@ export type CastClip = {
16
16
  bytes: Uint8Array;
17
17
  type: string;
18
18
  };
19
- /** What `announce` did: whether the clip played to its end, why not, and what it put back or could not. */
19
+ /** One Cast message, as a dry run reports it: the device it goes to, the channel (`receiver` or the app's `media`) and the payload. */
20
+ export type CastRequest = {
21
+ device: string;
22
+ channel: "receiver" | "media";
23
+ payload: Record<string, unknown>;
24
+ };
25
+ /** `dryRun`: run every check a real write runs (the target, its connection, the input) and the reads it makes first, send no write, and say what would go. */
26
+ export type CastWriteOptions = {
27
+ dryRun?: boolean;
28
+ };
29
+ /** A dry run's answer: the messages in order; empty when there is nothing to send. */
30
+ export type CastDryRun = {
31
+ wouldSend: CastRequest[];
32
+ };
33
+ /**
34
+ * What `announce` did: whether the clip played to its end, why not, and what it put back or could not. A dry run has
35
+ * `played: false`, `why: "dry run"`, `wouldSend`, and in `notRestored` what the real one could not put back.
36
+ */
20
37
  export type AnnounceResult = {
21
38
  played: boolean;
22
39
  why?: string;
@@ -25,6 +42,7 @@ export type AnnounceResult = {
25
42
  name: string;
26
43
  why: string;
27
44
  }[];
45
+ wouldSend?: CastRequest[];
28
46
  };
29
47
  /** Serves bytes once over HTTP on the LAN: `serveClip` from `@khalido/home/cast/serve`. `host` is the device that will fetch it. */
30
48
  export type ServeClip = (bytes: Uint8Array, type: string, options?: {
@@ -36,19 +54,20 @@ export type ServeClip = (bytes: Uint8Array, type: string, options?: {
36
54
  }>;
37
55
  /** The writes on `cast()`'s object. A target is a device's or group's name (any case) or its castId; volume is 0 to 1, as the snapshot's. */
38
56
  export type CastActions = {
39
- setVolume(target: string, level: number): Promise<void>;
40
- setMuted(target: string, muted: boolean): Promise<void>;
57
+ /** Resolves `undefined` once sent, or a `CastDryRun` under `dryRun`. */
58
+ setVolume(target: string, level: number, options?: CastWriteOptions): Promise<CastDryRun | undefined>;
59
+ setMuted(target: string, muted: boolean, options?: CastWriteOptions): Promise<CastDryRun | undefined>;
41
60
  /** Ends the running app (receiver STOP), which returns the device to its idle screen. Nothing running: nothing sent. */
42
- stop(target: string): Promise<void>;
61
+ stop(target: string, options?: CastWriteOptions): Promise<CastDryRun | undefined>;
43
62
  announce(target: string, clip: CastClip, options?: {
44
63
  volume?: number;
45
64
  timeoutMs?: number;
46
- }): Promise<AnnounceResult>;
65
+ } & CastWriteOptions): Promise<AnnounceResult>;
47
66
  /** `announce` to several targets at once, one result per device, in the order given. A target that can't be reached is its own `played: false`. A group and one of its own members are not told apart. */
48
67
  announceAll(targets: string[], clip: CastClip, options?: {
49
68
  volume?: number;
50
69
  timeoutMs?: number;
51
- }): Promise<(AnnounceResult & {
70
+ } & CastWriteOptions): Promise<(AnnounceResult & {
52
71
  target: string;
53
72
  })[]>;
54
73
  };
@@ -1,5 +1,6 @@
1
1
  import { errorText } from "../../connector.js";
2
2
  import { writeLane } from "../../lane.js";
3
+ import { CAST_WAV, castWav, isWav, wavText } from "./wav.js";
3
4
  import { NS } from "./codec.js";
4
5
  import { MediaStatus, MultizoneStatus, ReceiverStatus, } from "./schemas.js";
5
6
  import { castKey, DEFAULT_MEDIA_RECEIVER, findCast, } from "./state.js";
@@ -221,22 +222,51 @@ export function castActions(ctx) {
221
222
  .get(id)
222
223
  .send("level" in volume ? "level" : "muted", volume, (v) => receiver(live(find(target)), { type: "SET_VOLUME", volume: v }));
223
224
  };
225
+ const toReceiver = (d, payload) => ({ device: d.name, channel: "receiver", payload });
226
+ /** A dry SET_VOLUME: the target found and connected, nothing sent. */
227
+ const dryVolume = (target, volume) => {
228
+ const d = find(target);
229
+ live(d);
230
+ return {
231
+ wouldSend: [toReceiver(d, { type: "SET_VOLUME", volume })],
232
+ };
233
+ };
234
+ /** Each WAV clip made here, and the format it came from, so a dry run can say what was converted. */
235
+ const fromWav = new WeakMap();
236
+ /** WAV bytes as 48 kHz 16-bit stereo, which audio-only speakers play (`wav.ts`); any other clip as it is. */
237
+ const playable = (clip) => {
238
+ if ("url" in clip || !isWav(clip.type) || fromWav.has(clip.bytes))
239
+ return clip;
240
+ const r = castWav(clip.bytes);
241
+ if (r.converted)
242
+ fromWav.set(r.bytes, r.from);
243
+ return { bytes: r.bytes, type: "audio/wav" };
244
+ };
224
245
  const actions = {
225
- async setVolume(target, level) {
246
+ async setVolume(target, level, options) {
226
247
  checkLevel(level);
248
+ if (options?.dryRun)
249
+ return dryVolume(target, { level });
227
250
  await setVolume(target, { level });
228
251
  },
229
- async setMuted(target, muted) {
252
+ async setMuted(target, muted, options) {
253
+ if (options?.dryRun)
254
+ return dryVolume(target, { muted });
230
255
  await setVolume(target, { muted });
231
256
  },
232
- async stop(target) {
233
- const s = live(find(target));
257
+ async stop(target, options) {
258
+ const d = find(target);
259
+ const s = live(d);
234
260
  const app = (await receiver(s)).applications?.[0];
235
- if (app && !app.isIdleScreen)
236
- await receiver(s, {
237
- type: "STOP",
238
- ...(app.sessionId ? { sessionId: app.sessionId } : {}),
239
- });
261
+ const stop = {
262
+ type: "STOP",
263
+ ...(app?.sessionId ? { sessionId: app.sessionId } : {}),
264
+ };
265
+ const running = app && !app.isIdleScreen;
266
+ if (options?.dryRun)
267
+ return { wouldSend: running ? [toReceiver(d, stop)] : [] };
268
+ if (running)
269
+ await receiver(s, stop);
240
270
  },
241
271
  /**
242
272
  * Read the target's state, set the volume if asked and unmute a muted target, launch the Default Media Receiver, play the clip to its end, then
@@ -244,11 +274,13 @@ export function castActions(ctx) {
244
274
  * device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
245
275
  * (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
246
276
  */
247
- async announce(target, clip, options = {}) {
277
+ async announce(target, given, options = {}) {
248
278
  if (options.volume !== undefined)
249
279
  checkLevel(options.volume);
250
- if (!("url" in clip) && !ctx.serve)
280
+ if (!("url" in given) && !ctx.serve)
251
281
  throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
282
+ const clip = playable(given); // a bad WAV throws here, before anything changed
283
+ const wavFrom = "url" in clip ? undefined : fromWav.get(clip.bytes);
252
284
  let d = find(target);
253
285
  const s = d.kind === "group" ? await regroup(d) : live(d);
254
286
  d = ctx.devices()[d.id] ?? d; // a group's host as discovery gives it now, for serving the clip on its network
@@ -266,6 +298,8 @@ export function castActions(ctx) {
266
298
  restored: [],
267
299
  notRestored: [],
268
300
  };
301
+ if (options.dryRun)
302
+ return dryAnnounce();
269
303
  const serving = new AbortController();
270
304
  let served;
271
305
  let volumeSet = false;
@@ -311,6 +345,52 @@ export function castActions(ctx) {
311
345
  await restoreVolume();
312
346
  }
313
347
  return result;
348
+ /** What the announce would send, from the reads above; what it could not put back goes in `notRestored`. */
349
+ function dryAnnounce() {
350
+ const to = (device, payload) => ({
351
+ device: device.name,
352
+ channel: "receiver",
353
+ payload,
354
+ });
355
+ const media = (payload) => ({
356
+ device: d.name,
357
+ channel: "media",
358
+ payload,
359
+ });
360
+ const sends = [];
361
+ if (options.volume !== undefined)
362
+ sends.push(to(d, { type: "SET_VOLUME", volume: { level: options.volume } }));
363
+ if (unmute)
364
+ sends.push(to(d, { type: "SET_VOLUME", volume: { muted: false } }));
365
+ sends.push(to(d, { type: "LAUNCH", appId: DEFAULT_MEDIA_RECEIVER }), media({
366
+ type: "LOAD",
367
+ media: {
368
+ contentId: "url" in clip
369
+ ? clip.url
370
+ : `(${clip.bytes.length} bytes of ${clip.type}${wavFrom ? `, converted from ${wavText(wavFrom)} to ${wavText(CAST_WAV)}` : ""}, served on the LAN)`,
371
+ contentType: clip.type ?? "audio/mpeg",
372
+ streamType: "BUFFERED",
373
+ },
374
+ autoplay: true,
375
+ }));
376
+ if (priorMedia)
377
+ sends.push(media({ type: "LOAD", ...priorMedia }));
378
+ else {
379
+ sends.push(to(d, { type: "STOP" }));
380
+ if (!idleApp(prior) && prior?.appId !== DEFAULT_MEDIA_RECEIVER)
381
+ result.notRestored.push({
382
+ name: d.name,
383
+ why: `${prior?.displayName ?? prior?.appId ?? "an app"} would be interrupted and cannot be resumed from here`,
384
+ });
385
+ }
386
+ for (const step of volumeSteps())
387
+ if ("gap" in step)
388
+ result.notRestored.push({ name: step.name, why: step.gap });
389
+ else
390
+ for (const p of step.payloads)
391
+ sends.push(to(step, p));
392
+ return { ...result, why: "dry run", wouldSend: sends };
393
+ }
314
394
  async function restoreApp() {
315
395
  try {
316
396
  // A LAUNCH that timed out may still have started: look before deciding nothing of ours runs.
@@ -346,78 +426,91 @@ export function castActions(ctx) {
346
426
  });
347
427
  }
348
428
  }
349
- async function restoreVolume() {
350
- const each = d.kind === "group"
351
- ? members
352
- : [
353
- {
354
- id: d.id,
355
- name: d.name,
356
- level: before.volume?.level,
357
- muted: before.volume?.muted,
358
- },
359
- ];
360
- // A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
361
- // announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
429
+ /**
430
+ * The volume put back after the clip, as steps: a group's own level first (it keeps one of its own, which its members'
431
+ * levels don't bring back: live, 2026-11-10, it stayed at the announcement's 30 with every member restored), so
432
+ * whatever it does to the members, their own levels land last; then each member's, or the target's, through its own
433
+ * socket. A step it can't take is a gap with the reason. `restoreVolume` takes them; a dry run reports them.
434
+ */
435
+ function volumeSteps() {
436
+ const steps = [];
362
437
  if (d.kind === "group" && options.volume !== undefined) {
363
438
  const level = before.volume?.level;
364
- if (level === undefined)
365
- result.notRestored.push({
439
+ steps.push(level === undefined
440
+ ? { name: d.name, gap: "its volume before was not reported" }
441
+ : {
366
442
  name: d.name,
367
- why: "its volume before was not reported",
443
+ socket: s,
444
+ payloads: [{ type: "SET_VOLUME", volume: { level } }],
445
+ label: `${d.name}: volume`,
368
446
  });
369
- else
370
- try {
371
- await receiver(s, { type: "SET_VOLUME", volume: { level } });
372
- result.restored.push(`${d.name}: volume`);
373
- }
374
- catch (error) {
375
- result.notRestored.push({
376
- name: d.name,
377
- why: `volume not restored: ${errorText(error)}`,
378
- });
379
- }
380
447
  }
448
+ const each = d.kind === "group"
449
+ ? members
450
+ : options.volume !== undefined || unmute
451
+ ? [
452
+ {
453
+ id: d.id,
454
+ name: d.name,
455
+ level: before.volume?.level,
456
+ muted: before.volume?.muted,
457
+ },
458
+ ]
459
+ : [];
381
460
  for (const m of each) {
382
461
  const ms = m.id === d.id ? s : ctx.session(m.id);
383
462
  if (m.level === undefined)
384
- result.notRestored.push({
463
+ steps.push({
385
464
  name: m.name,
386
- why: "its volume before was not reported",
465
+ gap: "its volume before was not reported",
387
466
  });
388
467
  else if (!ms)
389
- result.notRestored.push({
468
+ steps.push({
390
469
  name: m.name,
391
- why: "not connected, so its volume stays as the announcement set it",
470
+ gap: "not connected, so its volume stays as the announcement set it",
392
471
  });
393
472
  else
394
- try {
395
- await receiver(ms, {
396
- type: "SET_VOLUME",
397
- volume: { level: m.level },
398
- });
399
- if (m.muted)
400
- await receiver(ms, {
401
- type: "SET_VOLUME",
402
- volume: { muted: true },
403
- });
404
- result.restored.push(`${m.name}: volume${m.muted ? ", muted" : ""}`);
405
- }
406
- catch (error) {
407
- result.notRestored.push({
408
- name: m.name,
409
- why: `volume not restored: ${errorText(error)}`,
410
- });
411
- }
473
+ steps.push({
474
+ name: m.name,
475
+ socket: ms,
476
+ payloads: [
477
+ { type: "SET_VOLUME", volume: { level: m.level } },
478
+ ...(m.muted
479
+ ? [{ type: "SET_VOLUME", volume: { muted: true } }]
480
+ : []),
481
+ ],
482
+ label: `${m.name}: volume${m.muted ? ", muted" : ""}`,
483
+ });
484
+ }
485
+ return steps;
486
+ }
487
+ async function restoreVolume() {
488
+ for (const step of volumeSteps()) {
489
+ if ("gap" in step) {
490
+ result.notRestored.push({ name: step.name, why: step.gap });
491
+ continue;
492
+ }
493
+ try {
494
+ for (const p of step.payloads)
495
+ await receiver(step.socket, p);
496
+ result.restored.push(step.label);
497
+ }
498
+ catch (error) {
499
+ result.notRestored.push({
500
+ name: step.name,
501
+ why: `volume not restored: ${errorText(error)}`,
502
+ });
503
+ }
412
504
  }
413
505
  }
414
506
  },
415
- async announceAll(targets, clip, options = {}) {
507
+ async announceAll(targets, given, options = {}) {
416
508
  // A caller's mistake is one error, not a failure per speaker.
417
509
  if (options.volume !== undefined)
418
510
  checkLevel(options.volume);
419
- if (!("url" in clip) && !ctx.serve)
511
+ if (!("url" in given) && !ctx.serve)
420
512
  throw new Error("a clip of bytes needs serve: serveClip from @khalido/home/cast/serve");
513
+ const clip = playable(given); // converted once, for every target
421
514
  const id = (t) => {
422
515
  try {
423
516
  return find(t).id;
@@ -30,10 +30,23 @@ export type SpotifyConfig = {
30
30
  };
31
31
  /** Soloist's repeat modes. */
32
32
  export type Repeat = "off" | "context" | "track";
33
- /** What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS` ran out without it. */
33
+ /** One WebSocket command, as a dry run reports it: Soloist's command name and its fields. */
34
+ export type SpotifyRequest = {
35
+ command: string;
36
+ fields?: Record<string, unknown>;
37
+ };
38
+ /** `dryRun`: run every check a real write runs (connected, signed in, the input), send nothing, and say what would go. */
39
+ export type SpotifyWriteOptions = {
40
+ dryRun?: boolean;
41
+ };
42
+ /**
43
+ * What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS`
44
+ * ran out without it. A dry run resolves `confirmed: false` with `wouldSend`, the commands in order; an `activate` already done resolves `confirmed: true` with `wouldSend: []`.
45
+ */
34
46
  export type Applied = {
35
47
  state: SpotifyState;
36
48
  confirmed: boolean;
49
+ wouldSend?: SpotifyRequest[];
37
50
  };
38
51
  /**
39
52
  * What happened to the music around `duck` or `interrupt`'s `fn`: `untouched` (it was not playing, not connected, already
@@ -54,36 +67,37 @@ export type Aside<T> = {
54
67
  */
55
68
  export type SpotifyActions = {
56
69
  /** Make this the account's active Spotify device. Soloist drops volume changes until it is. It makes a new audio stream, so the volume can jump to the level last saved. */
57
- activate(): Promise<Applied>;
70
+ activate(options?: SpotifyWriteOptions): Promise<Applied>;
58
71
  /** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
59
- play(linkOrUri?: string): Promise<Applied>;
60
- pause(): Promise<Applied>;
61
- resume(): Promise<Applied>;
62
- next(): Promise<Applied>;
63
- prev(): Promise<Applied>;
72
+ play(linkOrUri?: string, options?: SpotifyWriteOptions): Promise<Applied>;
73
+ pause(options?: SpotifyWriteOptions): Promise<Applied>;
74
+ resume(options?: SpotifyWriteOptions): Promise<Applied>;
75
+ next(options?: SpotifyWriteOptions): Promise<Applied>;
76
+ prev(options?: SpotifyWriteOptions): Promise<Applied>;
64
77
  /** Jump to a position in the current track, in ms. */
65
- seek(ms: number): Promise<Applied>;
78
+ seek(ms: number, options?: SpotifyWriteOptions): Promise<Applied>;
66
79
  /** Set the volume 0–100, held to `maxVolume` and rounded to a whole percent: `state.volume` is what Soloist then reports. */
67
- setVolume(volume: number): Promise<Applied>;
80
+ setVolume(volume: number, options?: SpotifyWriteOptions): Promise<Applied>;
68
81
  /** Add a track to the queue (Soloist queues tracks only); confirmed when it shows in `upNext`. */
69
- queue(track: string): Promise<Applied>;
70
- setShuffle(on: boolean): Promise<Applied>;
82
+ queue(track: string, options?: SpotifyWriteOptions): Promise<Applied>;
83
+ setShuffle(on: boolean, options?: SpotifyWriteOptions): Promise<Applied>;
71
84
  /** Repeat off, the context (playlist, album) or the track: two commands, in the order Soloist's docs give, as one write. */
72
- setRepeat(mode: Repeat): Promise<Applied>;
85
+ setRepeat(mode: Repeat, options?: SpotifyWriteOptions): Promise<Applied>;
73
86
  /**
74
87
  * Pause if playing, run `fn`, then resume, unless someone changed it meanwhile (resumed, stopped or skipped it from a
75
88
  * phone): then it is theirs and stays. The resume runs even if `fn` throws. Not playing or not connected: just `fn`.
76
89
  * One `interrupt` or `duck` at a time; a second waits for the first, so never call either from inside `fn` (it would wait
77
90
  * forever). Two announcements back to back belong in one `fn`, or the music comes back between them.
78
91
  */
79
- interrupt<T>(fn: () => Promise<T> | T): Promise<Aside<T>>;
92
+ interrupt<T>(fn: () => Promise<T> | T, options?: SpotifyWriteOptions): Promise<Aside<T>>;
80
93
  /**
81
94
  * Lower the volume to `to` while `fn` runs, then put the earlier volume back, unless someone set the volume meanwhile.
82
95
  * For a short announcement over the music. Not playing, not connected or already at `to` or below: just `fn`.
96
+ * With `dryRun`, `interrupt` and `duck` run `fn`, touch nothing, and say in `why` what they would have done.
83
97
  */
84
98
  duck<T>(fn: () => Promise<T> | T, options: {
85
99
  to: number;
86
- }): Promise<Aside<T>>;
100
+ } & SpotifyWriteOptions): Promise<Aside<T>>;
87
101
  };
88
102
  /**
89
103
  * A Spotify Soloist speaker as one integration: one WebSocket to Soloist, its `auth_state` and `playback_state` on connect,