@khalido/home 0.2.0 → 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.
Files changed (76) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/dist/connector.d.ts +2 -0
  3. package/dist/connector.js +3 -0
  4. package/dist/connectors/amber/client.d.ts +1 -1
  5. package/dist/connectors/amber/client.js +1 -1
  6. package/dist/connectors/amber/index.js +2 -2
  7. package/dist/connectors/cast/index.d.ts +2 -1
  8. package/dist/connectors/cast/index.js +1 -0
  9. package/dist/connectors/cast/wav.d.ts +31 -0
  10. package/dist/connectors/cast/wav.js +149 -0
  11. package/dist/connectors/cast/writes.d.ts +25 -6
  12. package/dist/connectors/cast/writes.js +155 -62
  13. package/dist/connectors/enphase/client.d.ts +1 -1
  14. package/dist/connectors/enphase/client.js +1 -1
  15. package/dist/connectors/enphase/index.js +2 -1
  16. package/dist/connectors/hue/events.d.ts +0 -2
  17. package/dist/connectors/hue/events.js +1 -2
  18. package/dist/connectors/hue/index.js +2 -2
  19. package/dist/connectors/printers/client.d.ts +41 -6
  20. package/dist/connectors/printers/client.js +81 -8
  21. package/dist/connectors/printers/codec.d.ts +17 -3
  22. package/dist/connectors/printers/codec.js +32 -8
  23. package/dist/connectors/printers/index.d.ts +2 -0
  24. package/dist/connectors/printers/index.js +4 -0
  25. package/dist/connectors/printers/raster.d.ts +30 -0
  26. package/dist/connectors/printers/raster.js +175 -0
  27. package/dist/connectors/printers/state.d.ts +2 -0
  28. package/dist/connectors/spotify/changes.d.ts +1 -1
  29. package/dist/connectors/spotify/changes.js +1 -1
  30. package/dist/connectors/spotify/index.d.ts +28 -14
  31. package/dist/connectors/spotify/index.js +89 -48
  32. package/dist/connectors/up/client.d.ts +1 -1
  33. package/dist/connectors/up/client.js +1 -1
  34. package/dist/connectors/up/index.js +2 -2
  35. package/dist/connectors/weather/google.js +2 -2
  36. package/docs/cast.md +1 -1
  37. package/docs/cli.md +3 -1
  38. package/docs/decisions.md +7 -0
  39. package/docs/ideas.md +2 -0
  40. package/docs/using.md +1 -1
  41. package/llms.txt +2 -2
  42. package/package.json +1 -1
  43. package/skills/draw-home/README.md +2 -0
  44. package/skills/use-home/SKILL.md +1 -1
  45. package/src/cli/cast.ts +41 -11
  46. package/src/cli/context.ts +1 -1
  47. package/src/cli/enphase.ts +31 -5
  48. package/src/cli/main.ts +14 -5
  49. package/src/cli/printers.ts +254 -3
  50. package/src/cli/spotify.ts +16 -12
  51. package/src/connector.ts +5 -0
  52. package/src/connectors/amber/README.md +1 -1
  53. package/src/connectors/amber/client.ts +1 -1
  54. package/src/connectors/amber/index.ts +2 -2
  55. package/src/connectors/cast/README.md +2 -0
  56. package/src/connectors/cast/index.ts +4 -0
  57. package/src/connectors/cast/wav.ts +181 -0
  58. package/src/connectors/cast/writes.ts +211 -71
  59. package/src/connectors/enphase/README.md +3 -1
  60. package/src/connectors/enphase/client.ts +1 -1
  61. package/src/connectors/enphase/index.ts +2 -1
  62. package/src/connectors/hue/events.ts +1 -4
  63. package/src/connectors/hue/index.ts +2 -2
  64. package/src/connectors/printers/README.md +9 -4
  65. package/src/connectors/printers/client.ts +186 -12
  66. package/src/connectors/printers/codec.ts +44 -10
  67. package/src/connectors/printers/index.ts +17 -0
  68. package/src/connectors/printers/raster.ts +215 -0
  69. package/src/connectors/printers/state.ts +2 -0
  70. package/src/connectors/spotify/README.md +5 -1
  71. package/src/connectors/spotify/changes.ts +1 -1
  72. package/src/connectors/spotify/index.ts +233 -106
  73. package/src/connectors/up/README.md +1 -1
  74. package/src/connectors/up/client.ts +1 -1
  75. package/src/connectors/up/index.ts +2 -2
  76. package/src/connectors/weather/google.ts +6 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ 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
+
14
+ ## [0.2.1] - 2026-11-12
15
+
16
+ ### Added
17
+
18
+ - **printers:** printing. `identifyPrinter`, `validateJob`, `printJob` and `cancelJob` over IPP, and `pwgRaster` for a printer that takes no PDF (8-bit grey PWG Raster). The snapshot's `Printer` gains `port` and `rp`, so a write can reach it. `home printers print <file.pdf> [printer]`, `identify` and `cancel`; `print --dry-run` has the printer validate the job and sends no job; a printer with no PDF needs poppler's `pdftoppm`. Live on a Brother HL-L2350DW.
19
+
20
+ ### Fixed
21
+
22
+ - **cli, enphase:** a stale `IQ_GATEWAY_HOST` no longer leaves `home enphase` refused for good: with the gateway's serial from the address cache, a failed read is followed by mDNS discovery, and the line says where the gateway moved. Without `IQ_GATEWAY_HOST` the address comes from the cache, then from mDNS.
23
+ - **amber, enphase, up:** a 403 is a rejected key (`unavailable`, `reason: "auth"`), as a 401 already was and as Hue and Google weather already read both. It was `disconnected` and retried forever. One check, `isRejectedKey` in `src/connector.ts`, now serves every connector and the CLI.
24
+ - **spotify:** after the first-play lowering ([spotify/soloist#23](https://github.com/spotify/soloist/issues/23)), `anomaly` no longer says only "lowered it" when the volume is back above `maxVolume`: it adds `it is back at <n>`. Soloist puts its own level back about 90 ms after `playing` when it goes active (live, 2026-11-11), and the cap leaves that level alone.
25
+
7
26
  ## [0.2.0] - 2026-11-12
8
27
 
9
28
  ### Breaking
@@ -34,7 +53,7 @@ All notable changes to home.ts (`@khalido/home` on npm), in [Keep a Changelog](h
34
53
  - **cast: `castFreeSpeakers(state)`**: every free speaker and display in one call, a whole group where all of it is free, never a TV; `free` goes straight to `announceAll`.
35
54
  - **cast: `castFree(state, targets, { forms })`**: with `forms: ["speaker", "display"]` a TV is left out (`why: "form"`, only when `forms` is given), and a group holding one becomes its other free members.
36
55
  - **`home.add(integration)` and `home.remove(name)`** on the home `createHome` returns: add one while the rest keep running (a bridge paired just now), replace one by name (the old run has ended before the new one starts; how an `unavailable` part comes back after a re-key), or stop one and drop its part. `start` now also waits for those added.
37
- - **spotify: `playingElsewhere(state)`**, and **`spotifyElsewhere(snapshot)`** from `@khalido/home`: the names of the Cast devices and groups playing that track in Spotify, matched by the track's name since Soloist doesn't say where the music is. Empty for a phone or a laptop. `homeDevices` and `home devices` put it in the speaker's line (`silent · "Song" is playing on Front room speaker`), and `spotifyText(state, on)` takes it. Tested on built snapshots only.
56
+ - **spotify: `playingElsewhere(state)`**, and **`spotifyElsewhere(snapshot)`** from `@khalido/home`: the names of the Cast devices and groups playing that track in Spotify, matched by the track's name since Soloist doesn't say where the music is. Empty for a phone or a laptop. `homeDevices` and `home devices` put it in the speaker's line (`silent · "Song" is playing on Den speaker`), and `spotifyText(state, on)` takes it. Tested on built snapshots only.
38
57
  - **hue: lights, rooms and scenes by name.** `client.light(name, change)`, `client.room(name, change)` and `client.scene(name, room?)`, each resolving `{ state, confirmed }` (`HueApplied`). `change` (`LightChange`) is `{ on?, brightness?, color?, kelvin?, transitionMs? }`: a colour as hex or `{ r, g, b }`, sent as `xy` inside the light's own gamut; a white temperature in kelvin, checked against the light's range. An unknown name or a value the light can't take throws `HueError` with `code: "invalid"` before anything is sent, and a light the bridge has lost throws with `code: "offline"`.
39
58
  - **hue: colour in the state.** Each light's `kelvin` and `kelvinRange`, and on a colour light its `xy`, `hex` and `gamut`. `colorToXy`, `xyToHex`, `kelvinToMirek`, `mirekToKelvin` and `hueText` are exported.
40
59
  - **`home light <name> …`**, and `home room` and `home scene` now take a brightness, a temperature (`2700k`) and a colour (`ff8800`) together, name a scene's room (`in <room>`), and print what the bridge then reported.
@@ -139,7 +158,10 @@ First release. Nine connectors: Hue, Daikin, Enphase, Google Cast, AirPrint prin
139
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.
140
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.
141
160
 
142
- [Unreleased]: https://github.com/khalido/home.ts/compare/v0.1.3...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
163
+ [0.2.1]: https://github.com/khalido/home.ts/compare/v0.2.0...v0.2.1
164
+ [0.2.0]: https://github.com/khalido/home.ts/compare/v0.1.3...v0.2.0
143
165
  [0.1.3]: https://github.com/khalido/home.ts/compare/v0.1.2...v0.1.3
144
166
  [0.1.2]: https://github.com/khalido/home.ts/compare/v0.1.1...v0.1.2
145
167
  [0.1.1]: https://github.com/khalido/home.ts/compare/v0.1.0...v0.1.1
@@ -23,6 +23,8 @@ export declare class ConnectorError extends Error {
23
23
  cause?: unknown;
24
24
  });
25
25
  }
26
+ /** A 401 or 403 from a device or service: the key or token is wrong or revoked. Every connector reads it as `reason: "auth"`. */
27
+ export declare const isRejectedKey: (error: unknown) => boolean;
26
28
  /** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
27
29
  export declare function fetchTimeout(fetcher: typeof fetch, url: string | URL, init?: Omit<RequestInit, "signal">, options?: {
28
30
  timeoutMs?: number;
package/dist/connector.js CHANGED
@@ -24,6 +24,9 @@ export class ConnectorError extends Error {
24
24
  this.code = options.code;
25
25
  }
26
26
  }
27
+ /** A 401 or 403 from a device or service: the key or token is wrong or revoked. Every connector reads it as `reason: "auth"`. */
28
+ export const isRejectedKey = (error) => error instanceof ConnectorError &&
29
+ (error.status === 401 || error.status === 403);
27
30
  /** `fetch` that gives up after `timeoutMs`, or as soon as `signal` (a run's) aborts. Returns the response whatever its status. */
28
31
  export function fetchTimeout(fetcher, url, init = {}, options = {}) {
29
32
  const timeout = AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS);
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ConnectorError } from "../../connector.ts";
3
- /** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
3
+ /** A failed Amber call. `status` 401 or 403 (`isRejectedKey`) means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
4
4
  export declare class AmberError extends ConnectorError {
5
5
  }
6
6
  /** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { ConnectorError, getJson } from "../../connector.js";
3
3
  // Amber Electric's public API v1 (https://app.amber.com.au/developers; the published spec is 1.0, there is no v2). Shapes checked against live replies,
4
4
  // 2026-10-13; every field optional so a change degrades a reading rather than throwing.
5
- /** A failed Amber call. `status` 401 means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
5
+ /** A failed Amber call. `status` 401 or 403 (`isRejectedKey`) means the key is wrong or revoked; 429 means the rate limit (50 per 5 min). */
6
6
  export class AmberError extends ConnectorError {
7
7
  }
8
8
  /** One 5-minute price interval on one channel. Prices are c/kWh including GST, as a cost: negative feed-in means you are paid. */
@@ -1,5 +1,5 @@
1
1
  import { asOf, realClock } from "../../clock.js";
2
- import { errorText } from "../../connector.js";
2
+ import { errorText, isRejectedKey } from "../../connector.js";
3
3
  import { z } from "zod";
4
4
  import { envName } from "../../home.js";
5
5
  import { energyNow } from "../../energy.js";
@@ -41,7 +41,7 @@ export function amber(config) {
41
41
  catch (error) {
42
42
  if (signal.aborted)
43
43
  return; // stopped mid-read: not a failure
44
- if (error instanceof AmberError && error.status === 401)
44
+ if (isRejectedKey(error))
45
45
  return update({
46
46
  status: "unavailable",
47
47
  reason: "auth",
@@ -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
  };