@domicile-desktop/system-audio 0.0.0-alpha-a330bf5f3aae
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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/asked.d.ts +6 -0
- package/dist/asked.js +7 -0
- package/dist/audio-error.d.ts +37 -0
- package/dist/audio-error.js +32 -0
- package/dist/audio.d.ts +79 -0
- package/dist/audio.js +17 -0
- package/dist/ids.d.ts +17 -0
- package/dist/ids.js +45 -0
- package/dist/meters.d.ts +3 -0
- package/dist/meters.js +120 -0
- package/dist/pactl.d.ts +14 -0
- package/dist/pactl.js +13 -0
- package/dist/peak.d.ts +8 -0
- package/dist/peak.js +16 -0
- package/dist/reading.d.ts +8 -0
- package/dist/reading.js +218 -0
- package/dist/request-queue.d.ts +10 -0
- package/dist/request-queue.js +38 -0
- package/dist/request.d.ts +53 -0
- package/dist/request.js +127 -0
- package/dist/sound-server.d.ts +47 -0
- package/dist/sound-server.js +30 -0
- package/dist/subscription.d.ts +5 -0
- package/dist/subscription.js +19 -0
- package/dist/watch-audio.d.ts +4 -0
- package/dist/watch-audio.js +118 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Connor Prussin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @domicile-desktop/system-audio
|
|
2
|
+
|
|
3
|
+
A shell's mixer: devices, streams, cards, volume, mute and level meters, read
|
|
4
|
+
from PulseAudio or PipeWire through `pactl` and `parec`.
|
|
5
|
+
|
|
6
|
+
- Runs them with `@domicile-desktop/sdk/system`'s `spawn` and `run`, in the C
|
|
7
|
+
locale. They must be on the compositor's `PATH`; the flake's wrapper adds
|
|
8
|
+
PulseAudio's.
|
|
9
|
+
- Published to npm. Ships built JavaScript and `.d.ts` from `dist/`.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { system } from "@domicile-desktop/sdk/system";
|
|
15
|
+
import { soundServer } from "@domicile-desktop/system-audio/sound-server";
|
|
16
|
+
|
|
17
|
+
const server = soundServer(system(domicile));
|
|
18
|
+
const stop = server.watch((audio) => draw(audio));
|
|
19
|
+
await server.setVolume(audio.outputs[0].id, 0.5);
|
|
20
|
+
const meters = server.meters((levels) => drawLevels(levels));
|
|
21
|
+
meters.meter(new Map([[id, audio.meters.get(id)]]));
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `watch` holds `pactl -f json subscribe` open and reports the whole state on
|
|
25
|
+
each settled burst of changes. Without a sound server it reports nothing,
|
|
26
|
+
logs once and retries every 5 s.
|
|
27
|
+
- Requests resolve `Result<Asked, AudioError>`. They run one at a time; a
|
|
28
|
+
volume overtaken by a later one for the same id is skipped
|
|
29
|
+
(`Asked.Overtaken`).
|
|
30
|
+
- Volumes are kept between 0 and 1.5 (pavucontrol's maximum); NaN is refused.
|
|
31
|
+
Ids not of the form `watch` reports are refused before `pactl` runs.
|
|
32
|
+
- Meters run one `parec` per id and report peaks 20 times a second. Metering a
|
|
33
|
+
microphone records it: call `stop` when the meters are not shown.
|
|
34
|
+
- The library's own meters, other mixers' meters and PipeWire filter streams
|
|
35
|
+
are left out of the stream lists.
|
|
36
|
+
- While the desktop is locked, only `watch`, and `setVolume` and `setMuted` on
|
|
37
|
+
an output, work ([LOCK.md](/docs/LOCK.md)). Everything else fails with
|
|
38
|
+
`locked`.
|
|
39
|
+
|
|
40
|
+
## Modules
|
|
41
|
+
|
|
42
|
+
| Module | What it is |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `./sound-server` | `soundServer(system)` and its types. |
|
|
45
|
+
| `./audio` | `Audio` and its devices, streams, cards and `Meter`s. |
|
|
46
|
+
| `./audio-error` | `AudioError`: why a request was refused. |
|
|
47
|
+
| `./asked` | `Asked`: what became of a request. |
|
|
48
|
+
|
|
49
|
+
## Test
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
bun run turbo test --filter @domicile-desktop/system-audio
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Unit tests read recorded `pactl -f json` output (`src/pactl.fixture.ts`)
|
|
56
|
+
through a fake `System` (`src/system.fixture.ts`).
|
package/dist/asked.d.ts
ADDED
package/dist/asked.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare enum AudioErrorKind {
|
|
2
|
+
/** A volume that is NaN or infinite. */
|
|
3
|
+
NotANumber = 0,
|
|
4
|
+
/** An id that must name a device and names a stream. */
|
|
5
|
+
NotADevice = 1,
|
|
6
|
+
/** A stream moved to a device of the other direction. */
|
|
7
|
+
Mismatched = 2,
|
|
8
|
+
/** An id `watchAudio` never reported. */
|
|
9
|
+
UnknownId = 3,
|
|
10
|
+
/** `pactl` could not run, or the sound server refused. */
|
|
11
|
+
Refused = 4
|
|
12
|
+
}
|
|
13
|
+
/** Why the sound server was not asked, or said no. */
|
|
14
|
+
export declare const AudioError: {
|
|
15
|
+
Mismatched: (stream: string, device: string) => {
|
|
16
|
+
device: string;
|
|
17
|
+
kind: AudioErrorKind.Mismatched;
|
|
18
|
+
stream: string;
|
|
19
|
+
};
|
|
20
|
+
NotADevice: (id: string) => {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: AudioErrorKind.NotADevice;
|
|
23
|
+
};
|
|
24
|
+
NotANumber: () => {
|
|
25
|
+
kind: AudioErrorKind.NotANumber;
|
|
26
|
+
};
|
|
27
|
+
/** `message` is `pactl`'s error output, or why it did not run. */
|
|
28
|
+
Refused: (message: string) => {
|
|
29
|
+
kind: AudioErrorKind.Refused;
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
UnknownId: (id: string) => {
|
|
33
|
+
id: string;
|
|
34
|
+
kind: AudioErrorKind.UnknownId;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export type AudioError = ReturnType<(typeof AudioError)[keyof typeof AudioError]>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export var AudioErrorKind;
|
|
2
|
+
(function (AudioErrorKind) {
|
|
3
|
+
/** A volume that is NaN or infinite. */
|
|
4
|
+
AudioErrorKind[AudioErrorKind["NotANumber"] = 0] = "NotANumber";
|
|
5
|
+
/** An id that must name a device and names a stream. */
|
|
6
|
+
AudioErrorKind[AudioErrorKind["NotADevice"] = 1] = "NotADevice";
|
|
7
|
+
/** A stream moved to a device of the other direction. */
|
|
8
|
+
AudioErrorKind[AudioErrorKind["Mismatched"] = 2] = "Mismatched";
|
|
9
|
+
/** An id `watchAudio` never reported. */
|
|
10
|
+
AudioErrorKind[AudioErrorKind["UnknownId"] = 3] = "UnknownId";
|
|
11
|
+
/** `pactl` could not run, or the sound server refused. */
|
|
12
|
+
AudioErrorKind[AudioErrorKind["Refused"] = 4] = "Refused";
|
|
13
|
+
})(AudioErrorKind || (AudioErrorKind = {}));
|
|
14
|
+
/** Why the sound server was not asked, or said no. */
|
|
15
|
+
export const AudioError = {
|
|
16
|
+
Mismatched: (stream, device) => ({
|
|
17
|
+
device,
|
|
18
|
+
kind: AudioErrorKind.Mismatched,
|
|
19
|
+
stream,
|
|
20
|
+
}),
|
|
21
|
+
NotADevice: (id) => ({
|
|
22
|
+
id,
|
|
23
|
+
kind: AudioErrorKind.NotADevice,
|
|
24
|
+
}),
|
|
25
|
+
NotANumber: () => ({ kind: AudioErrorKind.NotANumber }),
|
|
26
|
+
/** `message` is `pactl`'s error output, or why it did not run. */
|
|
27
|
+
Refused: (message) => ({
|
|
28
|
+
kind: AudioErrorKind.Refused,
|
|
29
|
+
message,
|
|
30
|
+
}),
|
|
31
|
+
UnknownId: (id) => ({ id, kind: AudioErrorKind.UnknownId }),
|
|
32
|
+
};
|
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** A port of a device, or a profile of a card: something to switch it to. */
|
|
2
|
+
export type AudioChoice = {
|
|
3
|
+
/** What `setPort` and `setProfile` name it by. */
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
/**
|
|
7
|
+
* `false` for a port with an empty jack, or a profile that needs one. It can
|
|
8
|
+
* still be selected.
|
|
9
|
+
*/
|
|
10
|
+
available: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** An output (a sink) or an input (a source). */
|
|
13
|
+
export type AudioDevice = {
|
|
14
|
+
id: string;
|
|
15
|
+
description: string;
|
|
16
|
+
/** The loudest channel's volume as a fraction of 100%. Can exceed 1. */
|
|
17
|
+
volume: number;
|
|
18
|
+
muted: boolean;
|
|
19
|
+
/** Whether new streams go to it. */
|
|
20
|
+
default: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether it is an output's monitor source, which records what the output
|
|
23
|
+
* plays. Always `false` for an output.
|
|
24
|
+
*/
|
|
25
|
+
monitor: boolean;
|
|
26
|
+
/** E.g. speakers, headphones, line in. Often empty. */
|
|
27
|
+
ports: readonly AudioChoice[];
|
|
28
|
+
/** The {@link AudioChoice.name} of the port in use, if it has ports. */
|
|
29
|
+
port: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
/** Something playing (a sink input) or recording (a source output). */
|
|
32
|
+
export type AudioStream = {
|
|
33
|
+
id: string;
|
|
34
|
+
/** The application playing or recording it. */
|
|
35
|
+
application: string;
|
|
36
|
+
/** The stream title, if the application set one. */
|
|
37
|
+
title: string | undefined;
|
|
38
|
+
volume: number;
|
|
39
|
+
muted: boolean;
|
|
40
|
+
/** The {@link AudioDevice.id} it plays to or records from, if listed. */
|
|
41
|
+
device: string | undefined;
|
|
42
|
+
};
|
|
43
|
+
/** A sound card and its profiles, which select the enabled devices. */
|
|
44
|
+
export type AudioCard = {
|
|
45
|
+
id: string;
|
|
46
|
+
description: string;
|
|
47
|
+
/** Sorted best first. */
|
|
48
|
+
profiles: readonly AudioChoice[];
|
|
49
|
+
/** The {@link AudioChoice.name} of the profile in use. */
|
|
50
|
+
profile: string | undefined;
|
|
51
|
+
};
|
|
52
|
+
export declare enum MeterKind {
|
|
53
|
+
/** A source by name: an input, or an output's monitor. */
|
|
54
|
+
Source = 0,
|
|
55
|
+
/** A playback stream by index, as pavucontrol meters one. */
|
|
56
|
+
Stream = 1
|
|
57
|
+
}
|
|
58
|
+
/** Where a level meter records from. */
|
|
59
|
+
export declare const Meter: {
|
|
60
|
+
Source: (name: string) => {
|
|
61
|
+
kind: MeterKind.Source;
|
|
62
|
+
name: string;
|
|
63
|
+
};
|
|
64
|
+
Stream: (index: number) => {
|
|
65
|
+
index: number;
|
|
66
|
+
kind: MeterKind.Stream;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
export type Meter = ReturnType<(typeof Meter)[keyof typeof Meter]>;
|
|
70
|
+
/** Every output, input, stream and card. */
|
|
71
|
+
export type Audio = {
|
|
72
|
+
outputs: readonly AudioDevice[];
|
|
73
|
+
inputs: readonly AudioDevice[];
|
|
74
|
+
playback: readonly AudioStream[];
|
|
75
|
+
recording: readonly AudioStream[];
|
|
76
|
+
cards: readonly AudioCard[];
|
|
77
|
+
/** Where each meterable device or stream id is metered from. */
|
|
78
|
+
meters: ReadonlyMap<string, Meter>;
|
|
79
|
+
};
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The sound server's state, as `watchAudio` reports it.
|
|
2
|
+
//
|
|
3
|
+
// Ids are opaque. A device id is `output:` or `input:` plus the device name,
|
|
4
|
+
// which survives a server restart. A stream id is `playback:` or `recording:`
|
|
5
|
+
// plus the stream index, which lasts as long as the stream.
|
|
6
|
+
export var MeterKind;
|
|
7
|
+
(function (MeterKind) {
|
|
8
|
+
/** A source by name: an input, or an output's monitor. */
|
|
9
|
+
MeterKind[MeterKind["Source"] = 0] = "Source";
|
|
10
|
+
/** A playback stream by index, as pavucontrol meters one. */
|
|
11
|
+
MeterKind[MeterKind["Stream"] = 1] = "Stream";
|
|
12
|
+
})(MeterKind || (MeterKind = {}));
|
|
13
|
+
/** Where a level meter records from. */
|
|
14
|
+
export const Meter = {
|
|
15
|
+
Source: (name) => ({ kind: MeterKind.Source, name }),
|
|
16
|
+
Stream: (index) => ({ index, kind: MeterKind.Stream }),
|
|
17
|
+
};
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Result } from "@cprussin/option-result";
|
|
2
|
+
import { AudioError } from "./audio-error";
|
|
3
|
+
export declare enum Target {
|
|
4
|
+
Output = 0,
|
|
5
|
+
Input = 1,
|
|
6
|
+
Playback = 2,
|
|
7
|
+
Recording = 3
|
|
8
|
+
}
|
|
9
|
+
export declare const targetId: (target: Target, key: string | number) => string;
|
|
10
|
+
/**
|
|
11
|
+
* Splits an id into its target and key. A stream's key must be its index, or
|
|
12
|
+
* `pactl` would look it up as a name.
|
|
13
|
+
*/
|
|
14
|
+
export declare const parseId: (id: string) => Result<{
|
|
15
|
+
target: Target;
|
|
16
|
+
key: string;
|
|
17
|
+
}, AudioError>;
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The kinds of thing an id names, and the ids themselves: the kind's prefix,
|
|
2
|
+
// a colon, then a device name or stream index.
|
|
3
|
+
import { Err, Ok } from "@cprussin/option-result";
|
|
4
|
+
import { AudioError } from "./audio-error";
|
|
5
|
+
export var Target;
|
|
6
|
+
(function (Target) {
|
|
7
|
+
Target[Target["Output"] = 0] = "Output";
|
|
8
|
+
Target[Target["Input"] = 1] = "Input";
|
|
9
|
+
Target[Target["Playback"] = 2] = "Playback";
|
|
10
|
+
Target[Target["Recording"] = 3] = "Recording";
|
|
11
|
+
})(Target || (Target = {}));
|
|
12
|
+
export const targetId = (target, key) => `${prefix(target)}:${key.toString()}`;
|
|
13
|
+
/**
|
|
14
|
+
* Splits an id into its target and key. A stream's key must be its index, or
|
|
15
|
+
* `pactl` would look it up as a name.
|
|
16
|
+
*/
|
|
17
|
+
export const parseId = (id) => {
|
|
18
|
+
const colon = id.indexOf(":");
|
|
19
|
+
const target = TARGETS.find((candidate) => prefix(candidate) === id.slice(0, colon));
|
|
20
|
+
const key = id.slice(colon + 1);
|
|
21
|
+
return colon === -1 || target === undefined || !fits(target, key)
|
|
22
|
+
? Err(AudioError.UnknownId(id))
|
|
23
|
+
: Ok({ key, target });
|
|
24
|
+
};
|
|
25
|
+
const TARGETS = [
|
|
26
|
+
Target.Output,
|
|
27
|
+
Target.Input,
|
|
28
|
+
Target.Playback,
|
|
29
|
+
Target.Recording,
|
|
30
|
+
];
|
|
31
|
+
const fits = (target, key) => target === Target.Playback || target === Target.Recording
|
|
32
|
+
? /^\d+$/.test(key)
|
|
33
|
+
: key !== "";
|
|
34
|
+
const prefix = (target) => {
|
|
35
|
+
switch (target) {
|
|
36
|
+
case Target.Output:
|
|
37
|
+
return "output";
|
|
38
|
+
case Target.Input:
|
|
39
|
+
return "input";
|
|
40
|
+
case Target.Playback:
|
|
41
|
+
return "playback";
|
|
42
|
+
case Target.Recording:
|
|
43
|
+
return "recording";
|
|
44
|
+
}
|
|
45
|
+
};
|
package/dist/meters.d.ts
ADDED
package/dist/meters.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Level meters: one `parec` per metered id, sampled for its peak.
|
|
2
|
+
//
|
|
3
|
+
// `parec` cannot use the server's own peak detection, so it records 1000 mono
|
|
4
|
+
// samples a second and the peak is taken here.
|
|
5
|
+
import { MeterKind } from "./audio";
|
|
6
|
+
import { C_LOCALE } from "./pactl";
|
|
7
|
+
import { peak } from "./peak";
|
|
8
|
+
import { METER_APPLICATION } from "./reading";
|
|
9
|
+
/** High enough to catch transients, low enough to run one per device. */
|
|
10
|
+
const RATE = 1000;
|
|
11
|
+
/** See `SoundServer.meters`. */
|
|
12
|
+
export const meters = (system, onLevels, tickMs, report) => {
|
|
13
|
+
const running = new Map();
|
|
14
|
+
const said = { cannotRecord: false };
|
|
15
|
+
const ticking = setInterval(() => {
|
|
16
|
+
const levels = levelsOf(running);
|
|
17
|
+
if (levels.size > 0) {
|
|
18
|
+
onLevels(levels);
|
|
19
|
+
}
|
|
20
|
+
}, tickMs);
|
|
21
|
+
return {
|
|
22
|
+
// A meter whose source changed restarts: a stream moved to another
|
|
23
|
+
// device keeps its id.
|
|
24
|
+
meter: (wanted) => {
|
|
25
|
+
for (const [id, recording] of running) {
|
|
26
|
+
const meter = wanted.get(id);
|
|
27
|
+
if (meter === undefined || !sameMeter(meter, recording.meter)) {
|
|
28
|
+
stop(recording);
|
|
29
|
+
running.delete(id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const [id, meter] of wanted) {
|
|
33
|
+
if (!running.has(id)) {
|
|
34
|
+
running.set(id, record(system, meter, report, said));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
stop: () => {
|
|
39
|
+
clearInterval(ticking);
|
|
40
|
+
for (const recording of running.values()) {
|
|
41
|
+
stop(recording);
|
|
42
|
+
}
|
|
43
|
+
running.clear();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/** `parec` arguments to record `meter` as mono floats, named as a meter. */
|
|
48
|
+
const argv = (meter) => [
|
|
49
|
+
"parec",
|
|
50
|
+
meter.kind === MeterKind.Source
|
|
51
|
+
? `--device=${meter.name}`
|
|
52
|
+
: `--monitor-stream=${meter.index.toString()}`,
|
|
53
|
+
"--raw",
|
|
54
|
+
"--format=float32le",
|
|
55
|
+
"--channels=1",
|
|
56
|
+
`--rate=${RATE.toString()}`,
|
|
57
|
+
"--latency-msec=30",
|
|
58
|
+
`--property=application.id=${METER_APPLICATION}`,
|
|
59
|
+
];
|
|
60
|
+
/** Starts a `parec` for `meter`. One that cannot start stays silent. */
|
|
61
|
+
const record = (system, meter, report, said) => {
|
|
62
|
+
const recording = {
|
|
63
|
+
loudest: undefined,
|
|
64
|
+
meter,
|
|
65
|
+
process: undefined,
|
|
66
|
+
stopped: false,
|
|
67
|
+
};
|
|
68
|
+
system
|
|
69
|
+
.spawn(argv(meter), C_LOCALE)
|
|
70
|
+
.then(async (started) => {
|
|
71
|
+
await started.match({
|
|
72
|
+
Err: (error) => {
|
|
73
|
+
if (!said.cannotRecord) {
|
|
74
|
+
said.cannotRecord = true;
|
|
75
|
+
report("the meters cannot record; they read nothing", error.message);
|
|
76
|
+
}
|
|
77
|
+
return Promise.resolve();
|
|
78
|
+
},
|
|
79
|
+
Ok: (process) => {
|
|
80
|
+
recording.process = process;
|
|
81
|
+
if (recording.stopped) {
|
|
82
|
+
process.kill();
|
|
83
|
+
}
|
|
84
|
+
return listen(process.stdout, recording);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
})
|
|
88
|
+
.catch((error) => {
|
|
89
|
+
// biome-ignore lint/suspicious/noConsole: a meter has no caller to return to
|
|
90
|
+
console.error("a level meter broke", error);
|
|
91
|
+
});
|
|
92
|
+
return recording;
|
|
93
|
+
};
|
|
94
|
+
/** Keeps `recording.loudest` at the loudest sample `samples` carried. */
|
|
95
|
+
const listen = async (samples, recording) => {
|
|
96
|
+
let carried = new Uint8Array();
|
|
97
|
+
for await (const read of samples) {
|
|
98
|
+
const { loudest, rest } = peak(carried, read);
|
|
99
|
+
recording.loudest = Math.max(recording.loudest ?? 0, loudest);
|
|
100
|
+
carried = rest;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const stop = (recording) => {
|
|
104
|
+
recording.stopped = true;
|
|
105
|
+
recording.process?.kill();
|
|
106
|
+
};
|
|
107
|
+
/** Each meter's peak since the last call, resetting it. Silent ones are left out. */
|
|
108
|
+
const levelsOf = (running) => new Map([...running].flatMap(([id, recording]) => {
|
|
109
|
+
const loudest = recording.loudest;
|
|
110
|
+
recording.loudest = loudest === undefined ? undefined : 0;
|
|
111
|
+
return loudest === undefined ? [] : [[id, loudest]];
|
|
112
|
+
}));
|
|
113
|
+
const sameMeter = (a, b) => {
|
|
114
|
+
switch (a.kind) {
|
|
115
|
+
case MeterKind.Source:
|
|
116
|
+
return b.kind === MeterKind.Source && a.name === b.name;
|
|
117
|
+
case MeterKind.Stream:
|
|
118
|
+
return b.kind === MeterKind.Stream && a.index === b.index;
|
|
119
|
+
}
|
|
120
|
+
};
|
package/dist/pactl.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Result } from "@cprussin/option-result";
|
|
2
|
+
import { AudioError } from "./audio-error";
|
|
3
|
+
import type { AudioSystem } from "./sound-server";
|
|
4
|
+
/**
|
|
5
|
+
* The C locale, because some of `pactl`'s JSON values are read as English
|
|
6
|
+
* words.
|
|
7
|
+
*/
|
|
8
|
+
export declare const C_LOCALE: {
|
|
9
|
+
env: {
|
|
10
|
+
LC_ALL: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** Runs `pactl args` and returns its output, or its error output as the error. */
|
|
14
|
+
export declare const pactl: (system: AudioSystem, args: readonly string[]) => Promise<Result<string, AudioError>>;
|
package/dist/pactl.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Err, Ok } from "@cprussin/option-result";
|
|
2
|
+
import { AudioError } from "./audio-error";
|
|
3
|
+
/**
|
|
4
|
+
* The C locale, because some of `pactl`'s JSON values are read as English
|
|
5
|
+
* words.
|
|
6
|
+
*/
|
|
7
|
+
export const C_LOCALE = { env: { LC_ALL: "C" } };
|
|
8
|
+
/** Runs `pactl args` and returns its output, or its error output as the error. */
|
|
9
|
+
export const pactl = async (system, args) => (await system.run(["pactl", ...args], C_LOCALE))
|
|
10
|
+
.mapErr((error) => AudioError.Refused(error.message))
|
|
11
|
+
.andThen((ran) => ran.code === 0
|
|
12
|
+
? Ok(ran.stdout)
|
|
13
|
+
: Err(AudioError.Refused(ran.stderr.trim())));
|
package/dist/peak.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loudest of the `f32` samples in `carried` then `read`, clipped to 1.
|
|
3
|
+
* `rest` is a trailing partial sample, to pass as `carried` with the next read.
|
|
4
|
+
*/
|
|
5
|
+
export declare const peak: (carried: Uint8Array, read: Uint8Array) => {
|
|
6
|
+
loudest: number;
|
|
7
|
+
rest: Uint8Array;
|
|
8
|
+
};
|
package/dist/peak.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Bytes in one little-endian `f32` sample. */
|
|
2
|
+
const SAMPLE = 4;
|
|
3
|
+
/**
|
|
4
|
+
* The loudest of the `f32` samples in `carried` then `read`, clipped to 1.
|
|
5
|
+
* `rest` is a trailing partial sample, to pass as `carried` with the next read.
|
|
6
|
+
*/
|
|
7
|
+
export const peak = (carried, read) => {
|
|
8
|
+
const bytes = new Uint8Array([...carried, ...read]);
|
|
9
|
+
const whole = bytes.length - (bytes.length % SAMPLE);
|
|
10
|
+
const view = new DataView(bytes.buffer, 0, whole);
|
|
11
|
+
const samples = Array.from({ length: whole / SAMPLE }, (_, at) => Math.abs(view.getFloat32(at * SAMPLE, true)));
|
|
12
|
+
return {
|
|
13
|
+
loudest: Math.min(1, Math.max(0, ...samples)),
|
|
14
|
+
rest: bytes.slice(whole),
|
|
15
|
+
};
|
|
16
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Result } from "@cprussin/option-result";
|
|
2
|
+
import type { Audio } from "./audio";
|
|
3
|
+
/** The raw volume `pactl` reports for 100%. */
|
|
4
|
+
export declare const NORMAL = 65536;
|
|
5
|
+
/** Application id of this library's meters, so they are hidden from the lists. */
|
|
6
|
+
export declare const METER_APPLICATION = "org.domicile.meter";
|
|
7
|
+
/** Parses the output of `pactl -f json info` and `pactl -f json list`. */
|
|
8
|
+
export declare const reading: (info: string, list: string) => Result<Audio, string>;
|
package/dist/reading.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Parses `pactl -f json info` and `pactl -f json list` into `Audio`.
|
|
2
|
+
//
|
|
3
|
+
// Run `pactl` under `LC_ALL=C`: it translates some values, such as a port's
|
|
4
|
+
// availability, that this module matches as English words.
|
|
5
|
+
import { Err, Ok } from "@cprussin/option-result";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { Meter } from "./audio";
|
|
8
|
+
import { Target, targetId } from "./ids";
|
|
9
|
+
/** The raw volume `pactl` reports for 100%. */
|
|
10
|
+
export const NORMAL = 65_536;
|
|
11
|
+
/** Application id of this library's meters, so they are hidden from the lists. */
|
|
12
|
+
export const METER_APPLICATION = "org.domicile.meter";
|
|
13
|
+
/** Applications whose streams are level meters. Matches pavucontrol's list. */
|
|
14
|
+
const MIXERS = new Set([
|
|
15
|
+
METER_APPLICATION,
|
|
16
|
+
"org.PulseAudio.pavucontrol",
|
|
17
|
+
"org.gnome.VolumeControl",
|
|
18
|
+
"org.kde.kmixd",
|
|
19
|
+
]);
|
|
20
|
+
/** PipeWire property shared by a filter's device and its stream. */
|
|
21
|
+
const LINK_GROUP = "node.link-group";
|
|
22
|
+
/** Parses the output of `pactl -f json info` and `pactl -f json list`. */
|
|
23
|
+
export const reading = (info, list) => {
|
|
24
|
+
const parsedInfo = parseJson(infoSchema, info);
|
|
25
|
+
const parsedList = parseJson(listSchema, list);
|
|
26
|
+
return parsedInfo.andThen((server) => parsedList.map((devices) => audioOf(server, devices)));
|
|
27
|
+
};
|
|
28
|
+
const volumeSchema = z.record(z.string(), z.unknown());
|
|
29
|
+
const propertiesSchema = z.record(z.string(), z.string()).default({});
|
|
30
|
+
const infoSchema = z.object({
|
|
31
|
+
default_sink_name: z.string().nullish(),
|
|
32
|
+
default_source_name: z.string().nullish(),
|
|
33
|
+
});
|
|
34
|
+
const deviceSchema = z.object({
|
|
35
|
+
active_port: z.string().nullish(),
|
|
36
|
+
description: z.string().nullish(),
|
|
37
|
+
index: z.number(),
|
|
38
|
+
/** On a source, the sink it monitors; on a sink, its monitor source. */
|
|
39
|
+
monitor_source: z.string().nullish(),
|
|
40
|
+
mute: z.boolean(),
|
|
41
|
+
name: z.string(),
|
|
42
|
+
ports: z
|
|
43
|
+
.array(z.object({
|
|
44
|
+
availability: z.string(),
|
|
45
|
+
description: z.string(),
|
|
46
|
+
name: z.string(),
|
|
47
|
+
}))
|
|
48
|
+
.default([]),
|
|
49
|
+
properties: propertiesSchema,
|
|
50
|
+
volume: volumeSchema,
|
|
51
|
+
});
|
|
52
|
+
const streamSchema = z.object({
|
|
53
|
+
index: z.number(),
|
|
54
|
+
mute: z.boolean(),
|
|
55
|
+
properties: propertiesSchema,
|
|
56
|
+
/** A sink input's device. */
|
|
57
|
+
sink: z.number().optional(),
|
|
58
|
+
/** A source output's device. */
|
|
59
|
+
source: z.number().optional(),
|
|
60
|
+
volume: volumeSchema,
|
|
61
|
+
});
|
|
62
|
+
const cardSchema = z.object({
|
|
63
|
+
active_profile: z.string().nullish(),
|
|
64
|
+
name: z.string(),
|
|
65
|
+
profiles: z.record(z.string(), z.object({
|
|
66
|
+
available: z.boolean(),
|
|
67
|
+
description: z.string(),
|
|
68
|
+
priority: z.number().default(0),
|
|
69
|
+
})),
|
|
70
|
+
properties: propertiesSchema,
|
|
71
|
+
});
|
|
72
|
+
const listSchema = z.object({
|
|
73
|
+
cards: z.array(cardSchema).default([]),
|
|
74
|
+
sink_inputs: z.array(streamSchema).default([]),
|
|
75
|
+
sinks: z.array(deviceSchema).default([]),
|
|
76
|
+
source_outputs: z.array(streamSchema).default([]),
|
|
77
|
+
sources: z.array(deviceSchema).default([]),
|
|
78
|
+
});
|
|
79
|
+
const parseJson = (schema, text) => {
|
|
80
|
+
const json = z.string().transform(jsonOf).pipe(schema).safeParse(text);
|
|
81
|
+
return json.success ? Ok(json.data) : Err(z.prettifyError(json.error));
|
|
82
|
+
};
|
|
83
|
+
const jsonOf = (text, context) => {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
context.addIssue({ code: "custom", input: text, message: "not JSON" });
|
|
89
|
+
return z.NEVER;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const audioOf = (info, list) => {
|
|
93
|
+
const outputsByIndex = new Map(list.sinks.map((sink) => [sink.index, targetId(Target.Output, sink.name)]));
|
|
94
|
+
const inputsByIndex = new Map(list.sources.map((source) => [
|
|
95
|
+
source.index,
|
|
96
|
+
targetId(Target.Input, source.name),
|
|
97
|
+
]));
|
|
98
|
+
const plumbing = new Set([...list.sinks, ...list.sources].flatMap((device) => {
|
|
99
|
+
const group = device.properties[LINK_GROUP];
|
|
100
|
+
return group === undefined ? [] : [group];
|
|
101
|
+
}));
|
|
102
|
+
const listed = (stream) => !isAMixers(stream) && !isPlumbing(stream, plumbing);
|
|
103
|
+
return {
|
|
104
|
+
cards: list.cards.map(card),
|
|
105
|
+
inputs: list.sources.map((source) => device(source, Target.Input, info.default_source_name)),
|
|
106
|
+
meters: meters(list),
|
|
107
|
+
outputs: list.sinks.map((sink) => device(sink, Target.Output, info.default_sink_name)),
|
|
108
|
+
playback: list.sink_inputs
|
|
109
|
+
.filter(listed)
|
|
110
|
+
.map((playing) => stream(playing, Target.Playback, outputsByIndex.get(playing.sink ?? -1))),
|
|
111
|
+
recording: list.source_outputs
|
|
112
|
+
.filter(listed)
|
|
113
|
+
.map((recording) => stream(recording, Target.Recording, inputsByIndex.get(recording.source ?? -1))),
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
/** An output's monitor source (if any), an input itself, or a stream. */
|
|
117
|
+
const meters = (list) => new Map([
|
|
118
|
+
...list.sinks.flatMap((sink) => sink.monitor_source === undefined || sink.monitor_source === null
|
|
119
|
+
? []
|
|
120
|
+
: [
|
|
121
|
+
[
|
|
122
|
+
targetId(Target.Output, sink.name),
|
|
123
|
+
Meter.Source(sink.monitor_source),
|
|
124
|
+
],
|
|
125
|
+
]),
|
|
126
|
+
...list.sources.map((source) => [
|
|
127
|
+
targetId(Target.Input, source.name),
|
|
128
|
+
Meter.Source(source.name),
|
|
129
|
+
]),
|
|
130
|
+
...list.sink_inputs.map((playing) => [
|
|
131
|
+
targetId(Target.Playback, playing.index),
|
|
132
|
+
Meter.Stream(playing.index),
|
|
133
|
+
]),
|
|
134
|
+
]);
|
|
135
|
+
const device = (from, kind, defaultName) => ({
|
|
136
|
+
default: defaultName === from.name,
|
|
137
|
+
description: from.description ?? from.name,
|
|
138
|
+
id: targetId(kind, from.name),
|
|
139
|
+
monitor: kind === Target.Input && isAMonitor(from),
|
|
140
|
+
muted: from.mute,
|
|
141
|
+
port: from.active_port ?? undefined,
|
|
142
|
+
ports: from.ports.map((port) => ({
|
|
143
|
+
available: port.availability !== "not available",
|
|
144
|
+
description: port.description,
|
|
145
|
+
name: port.name,
|
|
146
|
+
})),
|
|
147
|
+
volume: loudest(from.volume),
|
|
148
|
+
});
|
|
149
|
+
/**
|
|
150
|
+
* Whether a source monitors a sink. Uses `device.class` when the server sets
|
|
151
|
+
* it, else whether `monitor_source` names a sink. Some `pactl` versions write
|
|
152
|
+
* "none" there as `""` or `"n/a"`.
|
|
153
|
+
*/
|
|
154
|
+
const isAMonitor = (source) => {
|
|
155
|
+
const deviceClass = source.properties["device.class"];
|
|
156
|
+
return deviceClass === undefined
|
|
157
|
+
? namesASink(source.monitor_source)
|
|
158
|
+
: deviceClass === "monitor";
|
|
159
|
+
};
|
|
160
|
+
const namesASink = (sink) => sink !== undefined && sink !== null && sink !== "" && sink !== "n/a";
|
|
161
|
+
/**
|
|
162
|
+
* Uses the application name with the media name as title. Without an
|
|
163
|
+
* application name, the media name becomes the name and there is no title.
|
|
164
|
+
*/
|
|
165
|
+
const stream = (from, kind, device) => {
|
|
166
|
+
const media = from.properties["media.name"];
|
|
167
|
+
const application = from.properties["application.name"];
|
|
168
|
+
return {
|
|
169
|
+
application: application ?? media ?? "Unknown",
|
|
170
|
+
device,
|
|
171
|
+
id: targetId(kind, from.index),
|
|
172
|
+
muted: from.mute,
|
|
173
|
+
title: application === undefined ? undefined : media,
|
|
174
|
+
volume: loudest(from.volume),
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
const isAMixers = (stream) => {
|
|
178
|
+
const application = stream.properties["application.id"];
|
|
179
|
+
return application !== undefined && MIXERS.has(application);
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Whether a stream carries a filter device's audio to the hardware, such as a
|
|
183
|
+
* PipeWire filter-chain for speaker correction. It shares the device's link
|
|
184
|
+
* group.
|
|
185
|
+
*/
|
|
186
|
+
const isPlumbing = (stream, plumbing) => {
|
|
187
|
+
const group = stream.properties[LINK_GROUP];
|
|
188
|
+
return group !== undefined && plumbing.has(group);
|
|
189
|
+
};
|
|
190
|
+
const card = (from) => ({
|
|
191
|
+
description: from.properties["device.description"] ?? from.name,
|
|
192
|
+
id: from.name,
|
|
193
|
+
profile: from.active_profile ?? undefined,
|
|
194
|
+
profiles: Object.entries(from.profiles)
|
|
195
|
+
.toSorted(([aName, a], [bName, b]) => b.priority - a.priority || byCodeUnits(aName, bName))
|
|
196
|
+
.map(([name, profile]) => ({
|
|
197
|
+
available: profile.available,
|
|
198
|
+
description: profile.description,
|
|
199
|
+
name,
|
|
200
|
+
})),
|
|
201
|
+
});
|
|
202
|
+
/**
|
|
203
|
+
* The loudest channel as a fraction of 100%. An invalid volume, reported as
|
|
204
|
+
* `{"error": ...}`, reads as 0.
|
|
205
|
+
*/
|
|
206
|
+
const loudest = (volume) => Math.max(0, ...Object.values(volume).flatMap((channel) => {
|
|
207
|
+
const parsed = channelSchema.safeParse(channel);
|
|
208
|
+
return parsed.success ? [parsed.data.value / NORMAL] : [];
|
|
209
|
+
}));
|
|
210
|
+
const channelSchema = z.object({ value: z.number() });
|
|
211
|
+
const byCodeUnits = (a, b) => {
|
|
212
|
+
if (a < b) {
|
|
213
|
+
return -1;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
return a > b ? 1 : 0;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Result } from "@cprussin/option-result";
|
|
2
|
+
import { Asked } from "./asked";
|
|
3
|
+
import type { AudioError } from "./audio-error";
|
|
4
|
+
import type { Request } from "./request";
|
|
5
|
+
import type { AudioSystem } from "./sound-server";
|
|
6
|
+
/**
|
|
7
|
+
* Runs requests one at a time, in order. Requests that wait while another
|
|
8
|
+
* runs are coalesced: see {@link coalesce}.
|
|
9
|
+
*/
|
|
10
|
+
export declare const requestQueue: (system: AudioSystem) => (request: Request) => Promise<Result<Asked, AudioError>>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Err, Ok } from "@cprussin/option-result";
|
|
2
|
+
import { Asked } from "./asked";
|
|
3
|
+
import { pactl } from "./pactl";
|
|
4
|
+
import { argv, coalesce } from "./request";
|
|
5
|
+
/**
|
|
6
|
+
* Runs requests one at a time, in order. Requests that wait while another
|
|
7
|
+
* runs are coalesced: see {@link coalesce}.
|
|
8
|
+
*/
|
|
9
|
+
export const requestQueue = (system) => {
|
|
10
|
+
const waiting = [];
|
|
11
|
+
const state = { running: false };
|
|
12
|
+
return (request) => argv(request).match({
|
|
13
|
+
Err: (error) => Promise.resolve(Err(error)),
|
|
14
|
+
Ok: (args) => new Promise((answer) => {
|
|
15
|
+
waiting.push({ answer, args, request });
|
|
16
|
+
if (!state.running) {
|
|
17
|
+
state.running = true;
|
|
18
|
+
drain(system, waiting, state).catch((error) => {
|
|
19
|
+
// biome-ignore lint/suspicious/noConsole: no caller is waiting on the queue itself
|
|
20
|
+
console.error("the mixer's request queue broke", error);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
/** Runs what is waiting, then marks the queue idle in the same turn. */
|
|
27
|
+
const drain = async (system, waiting, state) => {
|
|
28
|
+
while (waiting.length > 0) {
|
|
29
|
+
const batch = waiting.splice(0);
|
|
30
|
+
const kept = coalesce(batch.map(({ request }) => request));
|
|
31
|
+
for (const { answer, args, request } of batch) {
|
|
32
|
+
answer(kept.includes(request)
|
|
33
|
+
? (await pactl(system, args)).map(() => Asked.Done)
|
|
34
|
+
: Ok(Asked.Overtaken));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
state.running = false;
|
|
38
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Result } from "@cprussin/option-result";
|
|
2
|
+
import { AudioError } from "./audio-error";
|
|
3
|
+
export declare enum RequestKind {
|
|
4
|
+
Volume = 0,
|
|
5
|
+
Muted = 1,
|
|
6
|
+
Default = 2,
|
|
7
|
+
Move = 3,
|
|
8
|
+
Port = 4,
|
|
9
|
+
Profile = 5
|
|
10
|
+
}
|
|
11
|
+
export declare const Request: {
|
|
12
|
+
Default: (id: string) => {
|
|
13
|
+
id: string;
|
|
14
|
+
kind: RequestKind.Default;
|
|
15
|
+
};
|
|
16
|
+
Move: (id: string, device: string) => {
|
|
17
|
+
device: string;
|
|
18
|
+
id: string;
|
|
19
|
+
kind: RequestKind.Move;
|
|
20
|
+
};
|
|
21
|
+
Muted: (id: string, muted: boolean) => {
|
|
22
|
+
id: string;
|
|
23
|
+
kind: RequestKind.Muted;
|
|
24
|
+
muted: boolean;
|
|
25
|
+
};
|
|
26
|
+
Port: (id: string, port: string) => {
|
|
27
|
+
id: string;
|
|
28
|
+
kind: RequestKind.Port;
|
|
29
|
+
port: string;
|
|
30
|
+
};
|
|
31
|
+
Profile: (card: string, profile: string) => {
|
|
32
|
+
card: string;
|
|
33
|
+
kind: RequestKind.Profile;
|
|
34
|
+
profile: string;
|
|
35
|
+
};
|
|
36
|
+
Volume: (id: string, volume: number) => {
|
|
37
|
+
id: string;
|
|
38
|
+
kind: RequestKind.Volume;
|
|
39
|
+
volume: number;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export type Request = ReturnType<(typeof Request)[keyof typeof Request]>;
|
|
43
|
+
/**
|
|
44
|
+
* `pactl` arguments for `request`. They start with `--` so a name cannot be
|
|
45
|
+
* read as an option.
|
|
46
|
+
*/
|
|
47
|
+
export declare const argv: (request: Request) => Result<string[], AudioError>;
|
|
48
|
+
/**
|
|
49
|
+
* Drops each volume request whose next request for the same target is also a
|
|
50
|
+
* volume. A dragged slider sends many requests a second; only the last
|
|
51
|
+
* matters.
|
|
52
|
+
*/
|
|
53
|
+
export declare const coalesce: (requests: readonly Request[]) => Request[];
|
package/dist/request.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// What a mixer asks the sound server, and the `pactl` arguments for it.
|
|
2
|
+
import { Err, Ok } from "@cprussin/option-result";
|
|
3
|
+
import { AudioError } from "./audio-error";
|
|
4
|
+
import { parseId, Target } from "./ids";
|
|
5
|
+
import { NORMAL } from "./reading";
|
|
6
|
+
/** The highest volume a mixer may set: 150%, matching pavucontrol. */
|
|
7
|
+
const CEILING = 1.5;
|
|
8
|
+
export var RequestKind;
|
|
9
|
+
(function (RequestKind) {
|
|
10
|
+
RequestKind[RequestKind["Volume"] = 0] = "Volume";
|
|
11
|
+
RequestKind[RequestKind["Muted"] = 1] = "Muted";
|
|
12
|
+
RequestKind[RequestKind["Default"] = 2] = "Default";
|
|
13
|
+
RequestKind[RequestKind["Move"] = 3] = "Move";
|
|
14
|
+
RequestKind[RequestKind["Port"] = 4] = "Port";
|
|
15
|
+
RequestKind[RequestKind["Profile"] = 5] = "Profile";
|
|
16
|
+
})(RequestKind || (RequestKind = {}));
|
|
17
|
+
export const Request = {
|
|
18
|
+
Default: (id) => ({ id, kind: RequestKind.Default }),
|
|
19
|
+
Move: (id, device) => ({
|
|
20
|
+
device,
|
|
21
|
+
id,
|
|
22
|
+
kind: RequestKind.Move,
|
|
23
|
+
}),
|
|
24
|
+
Muted: (id, muted) => ({
|
|
25
|
+
id,
|
|
26
|
+
kind: RequestKind.Muted,
|
|
27
|
+
muted,
|
|
28
|
+
}),
|
|
29
|
+
Port: (id, port) => ({
|
|
30
|
+
id,
|
|
31
|
+
kind: RequestKind.Port,
|
|
32
|
+
port,
|
|
33
|
+
}),
|
|
34
|
+
Profile: (card, profile) => ({
|
|
35
|
+
card,
|
|
36
|
+
kind: RequestKind.Profile,
|
|
37
|
+
profile,
|
|
38
|
+
}),
|
|
39
|
+
Volume: (id, volume) => ({
|
|
40
|
+
id,
|
|
41
|
+
kind: RequestKind.Volume,
|
|
42
|
+
volume,
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* `pactl` arguments for `request`. They start with `--` so a name cannot be
|
|
47
|
+
* read as an option.
|
|
48
|
+
*/
|
|
49
|
+
export const argv = (request) => words(request).map((args) => ["--", ...args]);
|
|
50
|
+
/**
|
|
51
|
+
* Drops each volume request whose next request for the same target is also a
|
|
52
|
+
* volume. A dragged slider sends many requests a second; only the last
|
|
53
|
+
* matters.
|
|
54
|
+
*/
|
|
55
|
+
export const coalesce = (requests) => requests.filter((request, at) => {
|
|
56
|
+
const next = requests
|
|
57
|
+
.slice(at + 1)
|
|
58
|
+
.find((later) => subject(later) === subject(request));
|
|
59
|
+
return !(request.kind === RequestKind.Volume && next?.kind === RequestKind.Volume);
|
|
60
|
+
});
|
|
61
|
+
const words = (request) => {
|
|
62
|
+
switch (request.kind) {
|
|
63
|
+
case RequestKind.Volume:
|
|
64
|
+
return rawVolume(request.volume).andThen((raw) => parseId(request.id).map(({ key, target }) => [
|
|
65
|
+
`set-${noun(target)}-volume`,
|
|
66
|
+
key,
|
|
67
|
+
raw.toString(),
|
|
68
|
+
]));
|
|
69
|
+
case RequestKind.Muted:
|
|
70
|
+
return parseId(request.id).map(({ key, target }) => [
|
|
71
|
+
`set-${noun(target)}-mute`,
|
|
72
|
+
key,
|
|
73
|
+
request.muted ? "1" : "0",
|
|
74
|
+
]);
|
|
75
|
+
case RequestKind.Default:
|
|
76
|
+
return device(request.id).map(({ key, target }) => [
|
|
77
|
+
`set-default-${noun(target)}`,
|
|
78
|
+
key,
|
|
79
|
+
]);
|
|
80
|
+
case RequestKind.Move:
|
|
81
|
+
return parseId(request.id).andThen((stream) => device(request.device).andThen((to) => moveVerb(stream.target, to.target).match({
|
|
82
|
+
Err: () => Err(AudioError.Mismatched(request.id, request.device)),
|
|
83
|
+
Ok: (verb) => Ok([verb, stream.key, to.key]),
|
|
84
|
+
})));
|
|
85
|
+
case RequestKind.Port:
|
|
86
|
+
return device(request.id).map(({ key, target }) => [
|
|
87
|
+
`set-${noun(target)}-port`,
|
|
88
|
+
key,
|
|
89
|
+
request.port,
|
|
90
|
+
]);
|
|
91
|
+
case RequestKind.Profile:
|
|
92
|
+
return Ok(["set-card-profile", request.card, request.profile]);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/** The id or card a request is about, for {@link coalesce}. */
|
|
96
|
+
const subject = (request) => request.kind === RequestKind.Profile ? request.card : request.id;
|
|
97
|
+
const rawVolume = (volume) => Number.isFinite(volume)
|
|
98
|
+
? Ok(Math.round(Math.min(Math.max(volume, 0), CEILING) * NORMAL))
|
|
99
|
+
: Err(AudioError.NotANumber());
|
|
100
|
+
/** Parses an id that must name a device. */
|
|
101
|
+
const device = (id) => parseId(id).andThen((parsed) => parsed.target === Target.Output || parsed.target === Target.Input
|
|
102
|
+
? Ok(parsed)
|
|
103
|
+
: Err(AudioError.NotADevice(id)));
|
|
104
|
+
const moveVerb = (stream, device) => {
|
|
105
|
+
if (stream === Target.Playback && device === Target.Output) {
|
|
106
|
+
return Ok("move-sink-input");
|
|
107
|
+
}
|
|
108
|
+
else if (stream === Target.Recording && device === Target.Input) {
|
|
109
|
+
return Ok("move-source-output");
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
return Err("mismatched");
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/** The `pactl` name for a target. */
|
|
116
|
+
const noun = (target) => {
|
|
117
|
+
switch (target) {
|
|
118
|
+
case Target.Output:
|
|
119
|
+
return "sink";
|
|
120
|
+
case Target.Input:
|
|
121
|
+
return "source";
|
|
122
|
+
case Target.Playback:
|
|
123
|
+
return "sink-input";
|
|
124
|
+
case Target.Recording:
|
|
125
|
+
return "source-output";
|
|
126
|
+
}
|
|
127
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Result } from "@cprussin/option-result";
|
|
2
|
+
import type { System } from "@domicile-desktop/sdk/system";
|
|
3
|
+
import type { Asked } from "./asked";
|
|
4
|
+
import type { Audio, Meter } from "./audio";
|
|
5
|
+
import type { AudioError } from "./audio-error";
|
|
6
|
+
/** The system calls this library uses. */
|
|
7
|
+
export type AudioSystem = Pick<System, "run" | "spawn">;
|
|
8
|
+
/** Each meter's loudest sample since the last report, 0 through 1, by id. */
|
|
9
|
+
export type Levels = ReadonlyMap<string, number>;
|
|
10
|
+
/** Running meters. Metering a microphone records it: stop when not shown. */
|
|
11
|
+
export type Meters = {
|
|
12
|
+
/** Meter these ids from these sources, and stop metering the rest. */
|
|
13
|
+
meter: (wanted: ReadonlyMap<string, Meter>) => void;
|
|
14
|
+
stop: () => void;
|
|
15
|
+
};
|
|
16
|
+
/** Ids come from {@link SoundServer.watch}; volumes are fractions of 100%. */
|
|
17
|
+
export type SoundServer = {
|
|
18
|
+
/**
|
|
19
|
+
* Calls `onAudio` with the server's state and again after each change.
|
|
20
|
+
* Never calls it without a sound server. Returns a stop function.
|
|
21
|
+
*/
|
|
22
|
+
watch: (onAudio: (audio: Audio) => void) => () => void;
|
|
23
|
+
/** Kept between 0 and 1.5, pavucontrol's maximum. */
|
|
24
|
+
setVolume: (id: string, volume: number) => Promise<Result<Asked, AudioError>>;
|
|
25
|
+
setMuted: (id: string, muted: boolean) => Promise<Result<Asked, AudioError>>;
|
|
26
|
+
/** Make a device the default for new streams. */
|
|
27
|
+
setDefault: (id: string) => Promise<Result<Asked, AudioError>>;
|
|
28
|
+
/** Move a stream to a device of its own direction. */
|
|
29
|
+
moveStream: (id: string, device: string) => Promise<Result<Asked, AudioError>>;
|
|
30
|
+
setPort: (id: string, port: string) => Promise<Result<Asked, AudioError>>;
|
|
31
|
+
setProfile: (card: string, profile: string) => Promise<Result<Asked, AudioError>>;
|
|
32
|
+
/** Level meters, reported to `onLevels` while any is running. */
|
|
33
|
+
meters: (onLevels: (levels: Levels) => void) => Meters;
|
|
34
|
+
};
|
|
35
|
+
/** How long to wait; shortened by tests. */
|
|
36
|
+
export type Timing = {
|
|
37
|
+
/** Quiet after a change before the server is read again. */
|
|
38
|
+
settleMs: number;
|
|
39
|
+
/** Delay before subscribing again after a subscription ends. */
|
|
40
|
+
reopenMs: number;
|
|
41
|
+
/** Interval between meter reports. */
|
|
42
|
+
tickMs: number;
|
|
43
|
+
};
|
|
44
|
+
/** Says that something keeps failing in the background. */
|
|
45
|
+
export type Report = (message: string, detail: unknown) => void;
|
|
46
|
+
/** The sound server, reached through `system`. */
|
|
47
|
+
export declare const soundServer: (system: AudioSystem, timing?: Timing, report?: Report) => SoundServer;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The sound server (PulseAudio or PipeWire) for a shell's mixer, through
|
|
2
|
+
// `pactl` and `parec` run by `@domicile-desktop/sdk/system`.
|
|
3
|
+
//
|
|
4
|
+
// - `pactl` and `parec` work with both PulseAudio and `pipewire-pulse`. The
|
|
5
|
+
// compositor's `PATH` must have them.
|
|
6
|
+
// - Requests run one at a time, in order, so the last volume a slider sent is
|
|
7
|
+
// the one the server keeps.
|
|
8
|
+
import { meters } from "./meters";
|
|
9
|
+
import { Request } from "./request";
|
|
10
|
+
import { requestQueue } from "./request-queue";
|
|
11
|
+
import { watchAudio } from "./watch-audio";
|
|
12
|
+
const TIMING = { reopenMs: 5000, settleMs: 30, tickMs: 50 };
|
|
13
|
+
/** The sound server, reached through `system`. */
|
|
14
|
+
export const soundServer = (system, timing = TIMING, report = warn) => {
|
|
15
|
+
const ask = requestQueue(system);
|
|
16
|
+
return {
|
|
17
|
+
meters: (onLevels) => meters(system, onLevels, timing.tickMs, report),
|
|
18
|
+
moveStream: (id, device) => ask(Request.Move(id, device)),
|
|
19
|
+
setDefault: (id) => ask(Request.Default(id)),
|
|
20
|
+
setMuted: (id, muted) => ask(Request.Muted(id, muted)),
|
|
21
|
+
setPort: (id, port) => ask(Request.Port(id, port)),
|
|
22
|
+
setProfile: (card, profile) => ask(Request.Profile(card, profile)),
|
|
23
|
+
setVolume: (id, volume) => ask(Request.Volume(id, volume)),
|
|
24
|
+
watch: (onAudio) => watchAudio(system, onAudio, timing, report),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const warn = (message, detail) => {
|
|
28
|
+
// biome-ignore lint/suspicious/noConsole: background failures have no caller to return to
|
|
29
|
+
console.warn(message, detail);
|
|
30
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* `pactl subscribe` facilities that change mixer state. Client events are
|
|
4
|
+
* left out: `pactl list` is itself a client, so rereading on them would loop.
|
|
5
|
+
*/
|
|
6
|
+
const NEWS = new Set([
|
|
7
|
+
"sink",
|
|
8
|
+
"source",
|
|
9
|
+
"sink-input",
|
|
10
|
+
"source-output",
|
|
11
|
+
"card",
|
|
12
|
+
"server",
|
|
13
|
+
]);
|
|
14
|
+
/**
|
|
15
|
+
* Whether a `pactl -f json subscribe` line means mixer state changed. Throws
|
|
16
|
+
* on a line that is not an event.
|
|
17
|
+
*/
|
|
18
|
+
export const announcesAChange = (line) => NEWS.has(eventSchema.parse(JSON.parse(line)).on);
|
|
19
|
+
const eventSchema = z.object({ on: z.string() });
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Audio } from "./audio";
|
|
2
|
+
import type { AudioSystem, Report, Timing } from "./sound-server";
|
|
3
|
+
/** See `SoundServer.watch`. */
|
|
4
|
+
export declare const watchAudio: (system: AudioSystem, onAudio: (audio: Audio) => void, timing: Timing, report: Report) => (() => void);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Holds `pactl subscribe` open and reads the server again after each burst of
|
|
2
|
+
// changes settles, subscribing again when the subscription ends.
|
|
3
|
+
import { AudioError } from "./audio-error";
|
|
4
|
+
import { C_LOCALE, pactl } from "./pactl";
|
|
5
|
+
import { reading } from "./reading";
|
|
6
|
+
import { announcesAChange } from "./subscription";
|
|
7
|
+
const SUBSCRIBE = ["pactl", "-f", "json", "subscribe"];
|
|
8
|
+
/** See `SoundServer.watch`. */
|
|
9
|
+
export const watchAudio = (system, onAudio, timing, report) => {
|
|
10
|
+
const stopping = new AbortController();
|
|
11
|
+
const told = (audio) => {
|
|
12
|
+
if (!stopping.signal.aborted) {
|
|
13
|
+
onAudio(audio);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
keepSubscribed(system, told, timing, report, stopping.signal).catch((error) => {
|
|
17
|
+
// biome-ignore lint/suspicious/noConsole: a broken watch has no caller to return to
|
|
18
|
+
console.error("the sound server watch broke", error);
|
|
19
|
+
});
|
|
20
|
+
return () => {
|
|
21
|
+
stopping.abort();
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** Subscribes until stopped, saying once if there is no sound server. */
|
|
25
|
+
const keepSubscribed = async (system, onAudio, timing, report, signal) => {
|
|
26
|
+
let said = false;
|
|
27
|
+
while (!signal.aborted) {
|
|
28
|
+
const ended = await subscribe(system, onAudio, timing, report, signal);
|
|
29
|
+
if (!said) {
|
|
30
|
+
said = ended.match({
|
|
31
|
+
Err: (error) => {
|
|
32
|
+
report("no sound server to read; the mixer is empty until there is", error);
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
Ok: () => false,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
await sleep(timing.reopenMs, signal);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* One subscription: reads the server, then again after each settled burst of
|
|
43
|
+
* changes, until `pactl` exits. Fails if the first read does.
|
|
44
|
+
*/
|
|
45
|
+
const subscribe = async (system, onAudio, timing, report, signal) => (await system.spawn(SUBSCRIBE, C_LOCALE))
|
|
46
|
+
.mapErr((error) => AudioError.Refused(error.message))
|
|
47
|
+
.andThenAsync(async (subscription) => {
|
|
48
|
+
const kill = () => {
|
|
49
|
+
subscription.kill();
|
|
50
|
+
};
|
|
51
|
+
signal.addEventListener("abort", kill);
|
|
52
|
+
const settling = {
|
|
53
|
+
reads: Promise.resolve(),
|
|
54
|
+
timer: undefined,
|
|
55
|
+
};
|
|
56
|
+
const changed = () => {
|
|
57
|
+
clearTimeout(settling.timer);
|
|
58
|
+
settling.timer = setTimeout(() => {
|
|
59
|
+
settling.reads = settling.reads.then(async () => {
|
|
60
|
+
(await readServer(system)).match({
|
|
61
|
+
Err: (error) => {
|
|
62
|
+
report("the sound server could not be read", error);
|
|
63
|
+
},
|
|
64
|
+
Ok: onAudio,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}, timing.settleMs);
|
|
68
|
+
};
|
|
69
|
+
// Read after subscribing, so no change in between is missed.
|
|
70
|
+
const first = await readServer(system);
|
|
71
|
+
first.match({
|
|
72
|
+
// Returned below, once the subscription ends.
|
|
73
|
+
Err: () => undefined,
|
|
74
|
+
Ok: onAudio,
|
|
75
|
+
});
|
|
76
|
+
await forEachLine(subscription.stdout, (line) => {
|
|
77
|
+
if (announcesAChange(line)) {
|
|
78
|
+
changed();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
await subscription.exited;
|
|
82
|
+
clearTimeout(settling.timer);
|
|
83
|
+
await settling.reads;
|
|
84
|
+
signal.removeEventListener("abort", kill);
|
|
85
|
+
return first.map(() => "ended");
|
|
86
|
+
});
|
|
87
|
+
const readServer = async (system) => {
|
|
88
|
+
const info = await pactl(system, ["-f", "json", "info"]);
|
|
89
|
+
const list = await pactl(system, ["-f", "json", "list"]);
|
|
90
|
+
return info.andThen((server) => list.map((devices) => parsed(server, devices)));
|
|
91
|
+
};
|
|
92
|
+
/** Throws on output that is not `pactl`'s JSON: a format this does not know. */
|
|
93
|
+
const parsed = (info, list) => reading(info, list).match({
|
|
94
|
+
Err: (why) => {
|
|
95
|
+
throw new Error(`unreadable pactl JSON: ${why}`);
|
|
96
|
+
},
|
|
97
|
+
Ok: (audio) => audio,
|
|
98
|
+
});
|
|
99
|
+
const forEachLine = async (stream, onLine) => {
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
let carried = "";
|
|
102
|
+
for await (const bytes of stream) {
|
|
103
|
+
const text = decoder.decode(bytes, { stream: true });
|
|
104
|
+
const lines = (carried + text).split("\n");
|
|
105
|
+
carried = lines.slice(-1).join("");
|
|
106
|
+
for (const line of lines.slice(0, -1)) {
|
|
107
|
+
onLine(line);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** Resolves after `ms`, or at once when `signal` aborts. */
|
|
112
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
113
|
+
const timer = setTimeout(resolve, ms);
|
|
114
|
+
signal.addEventListener("abort", () => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@cprussin/option-result": "^2.0.1",
|
|
4
|
+
"@domicile-desktop/sdk": "0.0.0-alpha-a330bf5f3aae",
|
|
5
|
+
"zod": "^4.5.4"
|
|
6
|
+
},
|
|
7
|
+
"description": "Volume, mute, devices, streams and level meters for a Domicile shell, through pactl and parec.",
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@cprussin/tsconfig": "^5.0.0",
|
|
10
|
+
"@types/bun": "^1.4.2",
|
|
11
|
+
"typescript": "^7.0.2"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
"./asked": {
|
|
15
|
+
"types": "./dist/asked.d.ts",
|
|
16
|
+
"default": "./dist/asked.js"
|
|
17
|
+
},
|
|
18
|
+
"./audio": {
|
|
19
|
+
"types": "./dist/audio.d.ts",
|
|
20
|
+
"default": "./dist/audio.js"
|
|
21
|
+
},
|
|
22
|
+
"./audio-error": {
|
|
23
|
+
"types": "./dist/audio-error.d.ts",
|
|
24
|
+
"default": "./dist/audio-error.js"
|
|
25
|
+
},
|
|
26
|
+
"./sound-server": {
|
|
27
|
+
"types": "./dist/sound-server.d.ts",
|
|
28
|
+
"default": "./dist/sound-server.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist/**"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"name": "@domicile-desktop/system-audio",
|
|
36
|
+
"private": false,
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"test:types": "tsc --noEmit",
|
|
43
|
+
"test:unit": "bun test"
|
|
44
|
+
},
|
|
45
|
+
"type": "module",
|
|
46
|
+
"version": "0.0.0-alpha-a330bf5f3aae",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/cprussin/domicile.git",
|
|
50
|
+
"directory": "packages/system-audio"
|
|
51
|
+
}
|
|
52
|
+
}
|