@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 +9 -1
- package/dist/connectors/cast/index.d.ts +2 -1
- package/dist/connectors/cast/index.js +1 -0
- package/dist/connectors/cast/wav.d.ts +31 -0
- package/dist/connectors/cast/wav.js +149 -0
- package/dist/connectors/cast/writes.d.ts +25 -6
- package/dist/connectors/cast/writes.js +155 -62
- package/dist/connectors/spotify/index.d.ts +28 -14
- package/dist/connectors/spotify/index.js +77 -47
- package/docs/cast.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/using.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/cast.ts +41 -11
- package/src/cli/context.ts +1 -1
- package/src/cli/main.ts +6 -2
- package/src/cli/spotify.ts +16 -12
- package/src/connectors/cast/README.md +2 -0
- package/src/connectors/cast/index.ts +4 -0
- package/src/connectors/cast/wav.ts +181 -0
- package/src/connectors/cast/writes.ts +211 -71
- package/src/connectors/spotify/README.md +2 -0
- package/src/connectors/spotify/index.ts +221 -105
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
64
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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,
|
|
495
|
+
async announce(target, given, options = {}) {
|
|
435
496
|
if (options.volume !== undefined) checkLevel(options.volume);
|
|
436
|
-
if (!("url" in
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
name: d.name,
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
703
|
+
steps.push({
|
|
577
704
|
name: m.name,
|
|
578
|
-
|
|
705
|
+
gap: "its volume before was not reported",
|
|
579
706
|
});
|
|
580
707
|
else if (!ms)
|
|
581
|
-
|
|
708
|
+
steps.push({
|
|
582
709
|
name: m.name,
|
|
583
|
-
|
|
710
|
+
gap: "not connected, so its volume stays as the announcement set it",
|
|
584
711
|
});
|
|
585
712
|
else
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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,
|
|
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
|
|
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.
|