@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.
@@ -0,0 +1,181 @@
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
+
7
+ /** What a WAV file says about its audio. */
8
+ export type WavFormat = {
9
+ sampleRate: number;
10
+ channels: number;
11
+ bitsPerSample: number;
12
+ /** 1 integer PCM, 3 IEEE float; WAVE_FORMAT_EXTENSIBLE is read as its sub-format. */
13
+ encoding: "pcm" | "float";
14
+ };
15
+
16
+ /** The rate, depth and channels every WAV clip is rewritten to. */
17
+ export const CAST_WAV: Omit<WavFormat, "encoding"> = {
18
+ sampleRate: 48_000,
19
+ channels: 2,
20
+ bitsPerSample: 16,
21
+ };
22
+
23
+ const WAV_TYPES = new Set([
24
+ "audio/wav",
25
+ "audio/wave",
26
+ "audio/x-wav",
27
+ "audio/vnd.wave",
28
+ ]);
29
+ /** Whether a clip's MIME type is WAV. */
30
+ export const isWav = (type: string | undefined) =>
31
+ !!type && WAV_TYPES.has(type.split(";")[0]!.trim().toLowerCase());
32
+
33
+ const ascii = (b: Uint8Array, at: number) =>
34
+ String.fromCharCode(b[at]!, b[at + 1]!, b[at + 2]!, b[at + 3]!);
35
+
36
+ /** A WAV file's format and its samples, each channel as floats −1 to 1. Throws on anything but integer or float PCM. */
37
+ export function readWav(bytes: Uint8Array): {
38
+ format: WavFormat;
39
+ channels: Float32Array[];
40
+ } {
41
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
42
+ if (
43
+ bytes.length < 12 ||
44
+ ascii(bytes, 0) !== "RIFF" ||
45
+ ascii(bytes, 8) !== "WAVE"
46
+ )
47
+ throw new Error("wav: not a RIFF WAVE file");
48
+ let format: WavFormat | undefined;
49
+ let blockAlign = 0;
50
+ let data: Uint8Array | undefined;
51
+ for (let at = 12; at + 8 <= bytes.length;) {
52
+ const id = ascii(bytes, at);
53
+ const size = view.getUint32(at + 4, true);
54
+ // 0xFFFFFFFF is a streaming writer's "unknown length" (ffmpeg on a pipe): to the end. Otherwise a chunk cut short is
55
+ // a file cut short, and a cut-off announcement must not play as if it were whole.
56
+ const end = size === 0xffffffff ? bytes.length : at + 8 + size;
57
+ if (end > bytes.length && (id === "fmt " || id === "data"))
58
+ throw new Error(
59
+ `wav: the ${id.trim()} chunk says ${size} bytes, the file has ${bytes.length - at - 8}`,
60
+ );
61
+ const body = bytes.subarray(at + 8, Math.min(end, bytes.length));
62
+ if (id === "fmt ") {
63
+ if (body.length < 16) throw new Error("wav: a fmt chunk under 16 bytes");
64
+ const f = new DataView(body.buffer, body.byteOffset, body.length);
65
+ let tag = f.getUint16(0, true);
66
+ if (tag === 0xfffe && body.length >= 26) tag = f.getUint16(24, true); // extensible: the sub-format GUID's first two bytes
67
+ if (tag !== 1 && tag !== 3)
68
+ throw new Error(`wav: format ${tag} is not PCM or float`);
69
+ format = {
70
+ channels: f.getUint16(2, true),
71
+ sampleRate: f.getUint32(4, true),
72
+ bitsPerSample: f.getUint16(14, true),
73
+ encoding: tag === 3 ? "float" : "pcm",
74
+ };
75
+ blockAlign = f.getUint16(12, true);
76
+ } else if (id === "data") data = body;
77
+ if (size === 0xffffffff) break;
78
+ at += 8 + size + (size % 2); // chunks are padded to an even length
79
+ }
80
+ if (!format) throw new Error("wav: no fmt chunk");
81
+ if (!data) throw new Error("wav: no data chunk");
82
+ const { channels: n, bitsPerSample: bits, encoding } = format;
83
+ const width = bits / 8;
84
+ if (
85
+ !n ||
86
+ !format.sampleRate ||
87
+ !(encoding === "float" ? bits === 32 : [8, 16, 24, 32].includes(bits))
88
+ )
89
+ throw new Error(
90
+ `wav: ${bits}-bit ${encoding}, ${n} channels at ${format.sampleRate} Hz is not read`,
91
+ );
92
+ if (blockAlign !== width * n)
93
+ throw new Error(
94
+ `wav: ${blockAlign} bytes a frame, not ${width * n} for ${n} × ${bits}-bit`,
95
+ );
96
+ const frames = Math.floor(data.length / (width * n));
97
+ if (!frames) throw new Error("wav: no samples");
98
+ const d = new DataView(data.buffer, data.byteOffset, data.length);
99
+ const out = Array.from({ length: n }, () => new Float32Array(frames));
100
+ for (let i = 0; i < frames; i++)
101
+ for (let c = 0; c < n; c++) {
102
+ const at = (i * n + c) * width;
103
+ out[c]![i] =
104
+ encoding === "float"
105
+ ? d.getFloat32(at, true)
106
+ : bits === 8
107
+ ? (d.getUint8(at) - 128) / 128
108
+ : bits === 16
109
+ ? d.getInt16(at, true) / 32768
110
+ : bits === 24
111
+ ? ((d.getUint8(at + 2) << 24) |
112
+ (d.getUint8(at + 1) << 16) |
113
+ (d.getUint8(at) << 8)) /
114
+ 2147483648
115
+ : d.getInt32(at, true) / 2147483648;
116
+ }
117
+ return { format, channels: out };
118
+ }
119
+
120
+ /**
121
+ * 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
122
+ * it is. Mono is copied to both channels; more than two channels keep the first two. A float sample past ±1 is limited to
123
+ * full scale, as any 16-bit output must.
124
+ */
125
+ export function castWav(bytes: Uint8Array): {
126
+ bytes: Uint8Array;
127
+ from: WavFormat;
128
+ converted: boolean;
129
+ } {
130
+ const { format, channels } = readWav(bytes);
131
+ const { sampleRate: rate, channels: n, bitsPerSample } = CAST_WAV;
132
+ if (
133
+ format.encoding === "pcm" &&
134
+ format.sampleRate === rate &&
135
+ format.channels === n &&
136
+ format.bitsPerSample === bitsPerSample
137
+ )
138
+ return { bytes, from: format, converted: false };
139
+ const src = [channels[0]!, channels[1] ?? channels[0]!];
140
+ const frames = Math.round((src[0]!.length * rate) / format.sampleRate);
141
+ const step = format.sampleRate / rate;
142
+ const out = new Uint8Array(44 + frames * n * 2);
143
+ const v = new DataView(out.buffer);
144
+ const text = (at: number, s: string) =>
145
+ [...s].forEach((ch, i) => v.setUint8(at + i, ch.charCodeAt(0)));
146
+ text(0, "RIFF");
147
+ v.setUint32(4, out.length - 8, true);
148
+ text(8, "WAVE");
149
+ text(12, "fmt ");
150
+ v.setUint32(16, 16, true);
151
+ v.setUint16(20, 1, true); // PCM
152
+ v.setUint16(22, n, true);
153
+ v.setUint32(24, rate, true);
154
+ v.setUint32(28, rate * n * 2, true); // bytes per second
155
+ v.setUint16(32, n * 2, true); // bytes per frame
156
+ v.setUint16(34, 16, true);
157
+ text(36, "data");
158
+ v.setUint32(40, frames * n * 2, true);
159
+ for (let i = 0; i < frames; i++) {
160
+ const x = i * step;
161
+ const j = Math.floor(x);
162
+ const t = x - j;
163
+ for (let c = 0; c < n; c++) {
164
+ const s = src[c]!;
165
+ const a = s[j] ?? 0;
166
+ const b = s[j + 1] ?? a;
167
+ const raw = a + (b - a) * t;
168
+ if (Number.isNaN(raw))
169
+ throw new Error(`wav: sample ${j} is not a number`);
170
+ const y = Math.max(-1, Math.min(1, raw));
171
+ v.setInt16(44 + (i * n + c) * 2, Math.round(y * 32767), true);
172
+ }
173
+ }
174
+ return { bytes: out, from: format, converted: true };
175
+ }
176
+
177
+ /** A format in a few words: `24000 Hz mono 16-bit`. */
178
+ export const wavText = (
179
+ f: Omit<WavFormat, "encoding"> & { encoding?: string },
180
+ ) =>
181
+ `${f.sampleRate} Hz ${f.channels === 1 ? "mono" : f.channels === 2 ? "stereo" : `${f.channels}-channel`} ${f.bitsPerSample}-bit${f.encoding === "float" ? " float" : ""}`;
@@ -1,6 +1,7 @@
1
1
  import type { Clock } from "../../clock.ts";
2
2
  import { errorText } from "../../connector.ts";
3
3
  import { type WriteLane, writeLane } from "../../lane.ts";
4
+ import { CAST_WAV, castWav, isWav, type WavFormat, wavText } from "./wav.ts";
4
5
  import { NS } from "./codec.ts";
5
6
  import {
6
7
  type Application,
@@ -43,12 +44,27 @@ const REFUSALS = new Set([
43
44
  export type CastClip =
44
45
  { url: string; type?: string } | { bytes: Uint8Array; type: string };
45
46
 
46
- /** What `announce` did: whether the clip played to its end, why not, and what it put back or could not. */
47
+ /** 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. */
48
+ export type CastRequest = {
49
+ device: string;
50
+ channel: "receiver" | "media";
51
+ payload: Record<string, unknown>;
52
+ };
53
+ /** `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. */
54
+ export type CastWriteOptions = { dryRun?: boolean };
55
+ /** A dry run's answer: the messages in order; empty when there is nothing to send. */
56
+ export type CastDryRun = { wouldSend: CastRequest[] };
57
+
58
+ /**
59
+ * What `announce` did: whether the clip played to its end, why not, and what it put back or could not. A dry run has
60
+ * `played: false`, `why: "dry run"`, `wouldSend`, and in `notRestored` what the real one could not put back.
61
+ */
47
62
  export type AnnounceResult = {
48
63
  played: boolean;
49
64
  why?: string;
50
65
  restored: string[];
51
66
  notRestored: { name: string; why: string }[];
67
+ wouldSend?: CastRequest[];
52
68
  };
53
69
 
54
70
  /** Serves bytes once over HTTP on the LAN: `serveClip` from `@khalido/home/cast/serve`. `host` is the device that will fetch it. */
@@ -60,20 +76,32 @@ export type ServeClip = (
60
76
 
61
77
  /** 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. */
62
78
  export type CastActions = {
63
- setVolume(target: string, level: number): Promise<void>;
64
- setMuted(target: string, muted: boolean): Promise<void>;
79
+ /** Resolves `undefined` once sent, or a `CastDryRun` under `dryRun`. */
80
+ setVolume(
81
+ target: string,
82
+ level: number,
83
+ options?: CastWriteOptions,
84
+ ): Promise<CastDryRun | undefined>;
85
+ setMuted(
86
+ target: string,
87
+ muted: boolean,
88
+ options?: CastWriteOptions,
89
+ ): Promise<CastDryRun | undefined>;
65
90
  /** Ends the running app (receiver STOP), which returns the device to its idle screen. Nothing running: nothing sent. */
66
- stop(target: string): Promise<void>;
91
+ stop(
92
+ target: string,
93
+ options?: CastWriteOptions,
94
+ ): Promise<CastDryRun | undefined>;
67
95
  announce(
68
96
  target: string,
69
97
  clip: CastClip,
70
- options?: { volume?: number; timeoutMs?: number },
98
+ options?: { volume?: number; timeoutMs?: number } & CastWriteOptions,
71
99
  ): Promise<AnnounceResult>;
72
100
  /** `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. */
73
101
  announceAll(
74
102
  targets: string[],
75
103
  clip: CastClip,
76
- options?: { volume?: number; timeoutMs?: number },
104
+ options?: { volume?: number; timeoutMs?: number } & CastWriteOptions,
77
105
  ): Promise<(AnnounceResult & { target: string })[]>;
78
106
  };
79
107
 
@@ -407,22 +435,55 @@ export function castActions(ctx: WriteContext): CastActions {
407
435
  );
408
436
  };
409
437
 
438
+ const toReceiver = (
439
+ d: CastDevice,
440
+ payload: Record<string, unknown>,
441
+ ): CastRequest => ({ device: d.name, channel: "receiver", payload });
442
+ /** A dry SET_VOLUME: the target found and connected, nothing sent. */
443
+ const dryVolume = (
444
+ target: string,
445
+ volume: { level: number } | { muted: boolean },
446
+ ): CastDryRun => {
447
+ const d = find(target);
448
+ live(d);
449
+ return {
450
+ wouldSend: [toReceiver(d, { type: "SET_VOLUME", volume })],
451
+ };
452
+ };
453
+
454
+ /** Each WAV clip made here, and the format it came from, so a dry run can say what was converted. */
455
+ const fromWav = new WeakMap<Uint8Array, WavFormat>();
456
+ /** WAV bytes as 48 kHz 16-bit stereo, which audio-only speakers play (`wav.ts`); any other clip as it is. */
457
+ const playable = (clip: CastClip): CastClip => {
458
+ if ("url" in clip || !isWav(clip.type) || fromWav.has(clip.bytes))
459
+ return clip;
460
+ const r = castWav(clip.bytes);
461
+ if (r.converted) fromWav.set(r.bytes, r.from);
462
+ return { bytes: r.bytes, type: "audio/wav" };
463
+ };
464
+
410
465
  const actions: CastActions = {
411
- async setVolume(target, level) {
466
+ async setVolume(target, level, options) {
412
467
  checkLevel(level);
468
+ if (options?.dryRun) return dryVolume(target, { level });
413
469
  await setVolume(target, { level });
414
470
  },
415
- async setMuted(target, muted) {
471
+ async setMuted(target, muted, options) {
472
+ if (options?.dryRun) return dryVolume(target, { muted });
416
473
  await setVolume(target, { muted });
417
474
  },
418
- async stop(target) {
419
- const s = live(find(target));
475
+ async stop(target, options) {
476
+ const d = find(target);
477
+ const s = live(d);
420
478
  const app = (await receiver(s)).applications?.[0];
421
- if (app && !app.isIdleScreen)
422
- await receiver(s, {
423
- type: "STOP",
424
- ...(app.sessionId ? { sessionId: app.sessionId } : {}),
425
- });
479
+ const stop = {
480
+ type: "STOP",
481
+ ...(app?.sessionId ? { sessionId: app.sessionId } : {}),
482
+ };
483
+ const running = app && !app.isIdleScreen;
484
+ if (options?.dryRun)
485
+ return { wouldSend: running ? [toReceiver(d, stop)] : [] };
486
+ if (running) await receiver(s, stop);
426
487
  },
427
488
 
428
489
  /**
@@ -431,12 +492,14 @@ export function castActions(ctx: WriteContext): CastActions {
431
492
  * device gets STOP, then the volume (a group's own first, then member by member through each member's own socket). Another app
432
493
  * (Spotify, YouTube) is interrupted and cannot be resumed from here; the result says so. Throws only before anything changed.
433
494
  */
434
- async announce(target, clip, options = {}) {
495
+ async announce(target, given, options = {}) {
435
496
  if (options.volume !== undefined) checkLevel(options.volume);
436
- if (!("url" in clip) && !ctx.serve)
497
+ if (!("url" in given) && !ctx.serve)
437
498
  throw new Error(
438
499
  "a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
439
500
  );
501
+ const clip = playable(given); // a bad WAV throws here, before anything changed
502
+ const wavFrom = "url" in clip ? undefined : fromWav.get(clip.bytes);
440
503
  let d = find(target);
441
504
  const s = d.kind === "group" ? await regroup(d) : live(d);
442
505
  d = ctx.devices()[d.id] ?? d; // a group's host as discovery gives it now, for serving the clip on its network
@@ -456,6 +519,7 @@ export function castActions(ctx: WriteContext): CastActions {
456
519
  restored: [],
457
520
  notRestored: [],
458
521
  };
522
+ if (options.dryRun) return dryAnnounce();
459
523
  const serving = new AbortController();
460
524
  let served: { close(): void } | undefined;
461
525
  let volumeSet = false;
@@ -501,6 +565,59 @@ export function castActions(ctx: WriteContext): CastActions {
501
565
  }
502
566
  return result;
503
567
 
568
+ /** What the announce would send, from the reads above; what it could not put back goes in `notRestored`. */
569
+ function dryAnnounce(): AnnounceResult {
570
+ const to = (
571
+ device: { name: string },
572
+ payload: Record<string, unknown>,
573
+ ): CastRequest => ({
574
+ device: device.name,
575
+ channel: "receiver",
576
+ payload,
577
+ });
578
+ const media = (payload: Record<string, unknown>): CastRequest => ({
579
+ device: d.name,
580
+ channel: "media",
581
+ payload,
582
+ });
583
+ const sends: CastRequest[] = [];
584
+ if (options.volume !== undefined)
585
+ sends.push(
586
+ to(d, { type: "SET_VOLUME", volume: { level: options.volume } }),
587
+ );
588
+ if (unmute)
589
+ sends.push(to(d, { type: "SET_VOLUME", volume: { muted: false } }));
590
+ sends.push(
591
+ to(d, { type: "LAUNCH", appId: DEFAULT_MEDIA_RECEIVER }),
592
+ media({
593
+ type: "LOAD",
594
+ media: {
595
+ contentId:
596
+ "url" in clip
597
+ ? clip.url
598
+ : `(${clip.bytes.length} bytes of ${clip.type}${wavFrom ? `, converted from ${wavText(wavFrom)} to ${wavText(CAST_WAV)}` : ""}, served on the LAN)`,
599
+ contentType: clip.type ?? "audio/mpeg",
600
+ streamType: "BUFFERED",
601
+ },
602
+ autoplay: true,
603
+ }),
604
+ );
605
+ if (priorMedia) sends.push(media({ type: "LOAD", ...priorMedia }));
606
+ else {
607
+ sends.push(to(d, { type: "STOP" }));
608
+ if (!idleApp(prior) && prior?.appId !== DEFAULT_MEDIA_RECEIVER)
609
+ result.notRestored.push({
610
+ name: d.name,
611
+ why: `${prior?.displayName ?? prior?.appId ?? "an app"} would be interrupted and cannot be resumed from here`,
612
+ });
613
+ }
614
+ for (const step of volumeSteps())
615
+ if ("gap" in step)
616
+ result.notRestored.push({ name: step.name, why: step.gap });
617
+ else for (const p of step.payloads) sends.push(to(step, p));
618
+ return { ...result, why: "dry run", wouldSend: sends };
619
+ }
620
+
504
621
  async function restoreApp() {
505
622
  try {
506
623
  // A LAUNCH that timed out may still have started: look before deciding nothing of ours runs.
@@ -538,81 +655,104 @@ export function castActions(ctx: WriteContext): CastActions {
538
655
  }
539
656
  }
540
657
 
541
- async function restoreVolume() {
542
- const each =
543
- d.kind === "group"
544
- ? members
545
- : [
546
- {
547
- id: d.id,
548
- name: d.name,
549
- level: before.volume?.level,
550
- muted: before.volume?.muted,
551
- },
552
- ];
553
- // A group keeps a level of its own, which its members' levels don't bring back (live, 2026-11-10: it stayed at the
554
- // announcement's 30 with every member restored). First, so whatever it does to the members, their own levels land last.
658
+ /**
659
+ * The volume put back after the clip, as steps: a group's own level first (it keeps one of its own, which its members'
660
+ * levels don't bring back: live, 2026-11-10, it stayed at the announcement's 30 with every member restored), so
661
+ * whatever it does to the members, their own levels land last; then each member's, or the target's, through its own
662
+ * socket. A step it can't take is a gap with the reason. `restoreVolume` takes them; a dry run reports them.
663
+ */
664
+ function volumeSteps(): (
665
+ | {
666
+ name: string;
667
+ socket: CastSession;
668
+ payloads: Record<string, unknown>[];
669
+ label: string;
670
+ }
671
+ | { name: string; gap: string }
672
+ )[] {
673
+ const steps: ReturnType<typeof volumeSteps> = [];
555
674
  if (d.kind === "group" && options.volume !== undefined) {
556
675
  const level = before.volume?.level;
557
- if (level === undefined)
558
- result.notRestored.push({
559
- name: d.name,
560
- why: "its volume before was not reported",
561
- });
562
- else
563
- try {
564
- await receiver(s, { type: "SET_VOLUME", volume: { level } });
565
- result.restored.push(`${d.name}: volume`);
566
- } catch (error) {
567
- result.notRestored.push({
568
- name: d.name,
569
- why: `volume not restored: ${errorText(error)}`,
570
- });
571
- }
676
+ steps.push(
677
+ level === undefined
678
+ ? { name: d.name, gap: "its volume before was not reported" }
679
+ : {
680
+ name: d.name,
681
+ socket: s,
682
+ payloads: [{ type: "SET_VOLUME", volume: { level } }],
683
+ label: `${d.name}: volume`,
684
+ },
685
+ );
572
686
  }
687
+ const each =
688
+ d.kind === "group"
689
+ ? members
690
+ : options.volume !== undefined || unmute
691
+ ? [
692
+ {
693
+ id: d.id,
694
+ name: d.name,
695
+ level: before.volume?.level,
696
+ muted: before.volume?.muted,
697
+ },
698
+ ]
699
+ : [];
573
700
  for (const m of each) {
574
701
  const ms = m.id === d.id ? s : ctx.session(m.id);
575
702
  if (m.level === undefined)
576
- result.notRestored.push({
703
+ steps.push({
577
704
  name: m.name,
578
- why: "its volume before was not reported",
705
+ gap: "its volume before was not reported",
579
706
  });
580
707
  else if (!ms)
581
- result.notRestored.push({
708
+ steps.push({
582
709
  name: m.name,
583
- why: "not connected, so its volume stays as the announcement set it",
710
+ gap: "not connected, so its volume stays as the announcement set it",
584
711
  });
585
712
  else
586
- try {
587
- await receiver(ms, {
588
- type: "SET_VOLUME",
589
- volume: { level: m.level },
590
- });
591
- if (m.muted)
592
- await receiver(ms, {
593
- type: "SET_VOLUME",
594
- volume: { muted: true },
595
- });
596
- result.restored.push(
597
- `${m.name}: volume${m.muted ? ", muted" : ""}`,
598
- );
599
- } catch (error) {
600
- result.notRestored.push({
601
- name: m.name,
602
- why: `volume not restored: ${errorText(error)}`,
603
- });
604
- }
713
+ steps.push({
714
+ name: m.name,
715
+ socket: ms,
716
+ payloads: [
717
+ { type: "SET_VOLUME", volume: { level: m.level } },
718
+ ...(m.muted
719
+ ? [{ type: "SET_VOLUME", volume: { muted: true } }]
720
+ : []),
721
+ ],
722
+ label: `${m.name}: volume${m.muted ? ", muted" : ""}`,
723
+ });
724
+ }
725
+ return steps;
726
+ }
727
+
728
+ async function restoreVolume() {
729
+ for (const step of volumeSteps()) {
730
+ if ("gap" in step) {
731
+ result.notRestored.push({ name: step.name, why: step.gap });
732
+ continue;
733
+ }
734
+ try {
735
+ for (const p of step.payloads)
736
+ await receiver(step.socket, p as { type: string });
737
+ result.restored.push(step.label);
738
+ } catch (error) {
739
+ result.notRestored.push({
740
+ name: step.name,
741
+ why: `volume not restored: ${errorText(error)}`,
742
+ });
743
+ }
605
744
  }
606
745
  }
607
746
  },
608
747
 
609
- async announceAll(targets, clip, options = {}) {
748
+ async announceAll(targets, given, options = {}) {
610
749
  // A caller's mistake is one error, not a failure per speaker.
611
750
  if (options.volume !== undefined) checkLevel(options.volume);
612
- if (!("url" in clip) && !ctx.serve)
751
+ if (!("url" in given) && !ctx.serve)
613
752
  throw new Error(
614
753
  "a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
615
754
  );
755
+ const clip = playable(given); // converted once, for every target
616
756
  const id = (t: string) => {
617
757
  try {
618
758
  return find(t).id;
@@ -26,6 +26,8 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
26
26
 
27
27
  **`maxVolume`** holds requests (rounded to a whole percent). For 10 s after a start signal, a reported volume above it is lowered: a start from idle (to playing or buffering), the first such status after a sign-in, `playing` after a start through buffering, or the speaker going active. Event order differs between runs, so no one pattern is relied on. It waits until the speaker is active, retries until Soloist reports the lower volume, and only then says so in `anomaly`, until the speaker goes idle; if it never shows or Soloist refuses it, `detail` says so. A reported volume within 1 of the cap is left alone. Not on resuming from pause while active, not on connecting to a song already playing, and a person turning it up after the lowering keeps it (before Soloist has reported the lowering, a person's raise looks like the start's own jump and is lowered too). `--initial-volume` is ignored on PipeWire ([#23](https://github.com/spotify/soloist/issues/23)).
28
28
 
29
+ **Dry run.** Every write takes `{ dryRun: true }` last: the checks run (connected, signed in, the input, `inactive` for `setVolume`, `maxVolume`), nothing is sent, and it resolves `confirmed: false` with `wouldSend`, the commands in order (an inactive speaker's `play` lists `activate` first). `duck` and `interrupt` with `dryRun` run `fn`, touch nothing, and say in `why` what they would have done; they don't wait behind a real `duck` or `interrupt`. Tested on a fake socket only.
30
+
29
31
  **`duck(fn, { to })` and `interrupt(fn)`** turn the music down, or pause it, around `fn`, then put it back. Both resolve `{ value, music, why? }`, `music` one of:
30
32
 
31
33
  - `untouched`: not playing or buffering, not connected, not the active device, (duck) the volume unknown or already at `to` or below, or the lowering or pause failed, so `fn` ran over it.