osdy-pi 0.1.5 → 0.1.7
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/README.md +69 -0
- package/extensions/osdy-pi/audio-event-router.ts +44 -0
- package/extensions/osdy-pi/audio-notification-config.ts +194 -0
- package/extensions/osdy-pi/audio-notification-service.ts +58 -0
- package/extensions/osdy-pi/audio-notification-types.ts +76 -0
- package/extensions/osdy-pi/audio-playback-macos.ts +36 -0
- package/extensions/osdy-pi/audio-playback-unsupported.ts +8 -0
- package/extensions/osdy-pi/audio-playback-windows.ts +86 -0
- package/extensions/osdy-pi/audio-playback.ts +18 -0
- package/extensions/osdy-pi/audio-sound-settings.ts +108 -0
- package/extensions/osdy-pi/constants.ts +244 -244
- package/extensions/osdy-pi/runtime.ts +81 -24
- package/extensions/osdy-pi/sound-setup-wizard.ts +220 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ Visit the Osdy landing page: [landing-osdy.vercel.app](https://landing-osdy.verc
|
|
|
15
15
|
- **Custom editor:** full-width framed input area with model, thinking, token, cost, and context status.
|
|
16
16
|
- **Custom working indicator:** a dedicated working widget/spinner appears above the text box, outside the editor frame.
|
|
17
17
|
- **Clean layout:** the built-in working row is hidden while Osdy Pi is enabled to avoid duplicated UI.
|
|
18
|
+
- **Optional audio notifications:** configurable `.mp3`/`.wav` files for `completion`, `error`, `permission`, and `question` events on macOS and Windows.
|
|
18
19
|
|
|
19
20
|
<img width="1280" height="433" alt="image" src="https://github.com/user-attachments/assets/20c7624d-9ad8-4494-97fb-6b6d81aaf328" />
|
|
20
21
|
|
|
@@ -74,6 +75,7 @@ Osdy Pi includes a small command group:
|
|
|
74
75
|
/osdy-pi enable
|
|
75
76
|
/osdy-pi disable
|
|
76
77
|
/osdy-pi status
|
|
78
|
+
/osdy-pi sound setup
|
|
77
79
|
/osdy-pi osdy-theme
|
|
78
80
|
/osdy-pi classic
|
|
79
81
|
/osdy-pi-osdy-theme
|
|
@@ -83,12 +85,79 @@ Osdy Pi includes a small command group:
|
|
|
83
85
|
- `enable` applies the dark Osdy theme, custom header, custom editor, and clean layout.
|
|
84
86
|
- `disable` restores Pi's built-in header, editor, footer, and working visibility, then switches back to the previous theme or `dark`.
|
|
85
87
|
- `status` shows whether the Osdy Pi UI is currently enabled, including the active style.
|
|
88
|
+
- `sound setup` opens the guided global sound-setup wizard for audio notifications.
|
|
86
89
|
- `osdy-theme` is the default OsdyTheme header with pink, cyan, and purple styling, plus the mascot glow on the right edge.
|
|
87
90
|
- `classic` keeps the previous classic header shape with the shared mascot.
|
|
88
91
|
- `/osdy-pi-osdy-theme` and `/osdy-pi-classic` are direct aliases.
|
|
89
92
|
|
|
90
93
|
After changing a local extension, run `/reload` or restart Pi so the updated commands are registered.
|
|
91
94
|
|
|
95
|
+
## Audio notifications
|
|
96
|
+
|
|
97
|
+
Osdy Pi can play your own sound files for these product-level events:
|
|
98
|
+
|
|
99
|
+
- `completion`: the full orchestrator flow finishes and Pi returns control to input.
|
|
100
|
+
- `error`: a real tool execution failure occurs during the flow.
|
|
101
|
+
- `permission`: reserved for future explicit Pi approval hooks, dormant by default today.
|
|
102
|
+
- `question`: reserved for future explicit Pi question hooks, dormant by default today.
|
|
103
|
+
|
|
104
|
+
Initial audio playback support is implemented for:
|
|
105
|
+
|
|
106
|
+
- macOS
|
|
107
|
+
- Windows
|
|
108
|
+
|
|
109
|
+
Unsupported platforms fall back safely without crashing Osdy Pi.
|
|
110
|
+
|
|
111
|
+
### Supported files
|
|
112
|
+
|
|
113
|
+
Only readable `.mp3` and `.wav` files are accepted.
|
|
114
|
+
|
|
115
|
+
### Configure sounds
|
|
116
|
+
|
|
117
|
+
The preferred setup path is the guided Osdy Pi wizard:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
/osdy-pi sound setup
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The wizard:
|
|
124
|
+
|
|
125
|
+
- walks through `completion`, `error`, `permission`, and `question`;
|
|
126
|
+
- lets you keep, replace, clear, or skip each event;
|
|
127
|
+
- validates every selected path before save;
|
|
128
|
+
- blocks save if any selected file is missing, unreadable, not a regular file, or not `.mp3`/`.wav`;
|
|
129
|
+
- stores accepted settings globally at `~/.pi/agent/extensions/osdy-pi/audio-notifications.json` (or `$PI_CODING_AGENT_DIR/extensions/osdy-pi/audio-notifications.json` when that env var is set).
|
|
130
|
+
|
|
131
|
+
Saved global sound paths apply across restarts and projects that use Osdy Pi.
|
|
132
|
+
|
|
133
|
+
### Startup flags still work
|
|
134
|
+
|
|
135
|
+
You can still pass sound paths as Pi flags when starting the session:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pi \
|
|
139
|
+
--osdy-pi-sound-completion /absolute/path/completion.wav \
|
|
140
|
+
--osdy-pi-sound-error /absolute/path/error.mp3 \
|
|
141
|
+
--osdy-pi-sound-permission /absolute/path/permission.wav \
|
|
142
|
+
--osdy-pi-sound-question /absolute/path/question.wav
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Precedence is per event:
|
|
146
|
+
|
|
147
|
+
1. startup flag
|
|
148
|
+
2. saved global Osdy Pi setting
|
|
149
|
+
3. unconfigured
|
|
150
|
+
|
|
151
|
+
Notes:
|
|
152
|
+
|
|
153
|
+
- Empty or omitted flags mean that event does not override the saved global setting.
|
|
154
|
+
- Relative startup-flag paths resolve from the current working directory.
|
|
155
|
+
- The setup wizard saves normalized absolute paths for global settings.
|
|
156
|
+
- `~` expands to your home directory.
|
|
157
|
+
- Invalid, unreadable, or unsupported files are skipped at playback time without changing existing UI behavior.
|
|
158
|
+
- If a file was valid when saved but later disappears or becomes unreadable, Osdy Pi fails safely and skips playback for that event.
|
|
159
|
+
- Audio notifications and sound setup are additive only, they do not change the current header, editor, footer, working indicator, theme, or commands.
|
|
160
|
+
|
|
92
161
|
## Local install
|
|
93
162
|
|
|
94
163
|
If you cloned this repository and want to test it locally:
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AudioNotificationService } from "./audio-notification-service.js";
|
|
3
|
+
|
|
4
|
+
export type AudioEventRouter = {
|
|
5
|
+
onAgentStart(): void;
|
|
6
|
+
onAgentEnd(ctx: ExtensionContext): void;
|
|
7
|
+
onToolExecutionEnd(isError: boolean, ctx: ExtensionContext): void;
|
|
8
|
+
onPermissionRequested(ctx: ExtensionContext): void;
|
|
9
|
+
onQuestionRequested(ctx: ExtensionContext): void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
class DefaultAudioEventRouter implements AudioEventRouter {
|
|
13
|
+
private errorNotified = false;
|
|
14
|
+
|
|
15
|
+
constructor(private readonly notificationService: AudioNotificationService) {}
|
|
16
|
+
|
|
17
|
+
onAgentStart(): void {
|
|
18
|
+
this.errorNotified = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
onAgentEnd(ctx: ExtensionContext): void {
|
|
22
|
+
this.notificationService.notify("completion", ctx);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
onToolExecutionEnd(isError: boolean, ctx: ExtensionContext): void {
|
|
26
|
+
if (!isError || this.errorNotified) return;
|
|
27
|
+
this.errorNotified = true;
|
|
28
|
+
this.notificationService.notify("error", ctx);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
onPermissionRequested(ctx: ExtensionContext): void {
|
|
32
|
+
this.notificationService.notify("permission", ctx);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onQuestionRequested(ctx: ExtensionContext): void {
|
|
36
|
+
this.notificationService.notify("question", ctx);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createAudioEventRouter(
|
|
41
|
+
notificationService: AudioNotificationService,
|
|
42
|
+
): AudioEventRouter {
|
|
43
|
+
return new DefaultAudioEventRouter(notificationService);
|
|
44
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { access, constants, stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { AudioSoundSettingsStore } from "./audio-sound-settings.js";
|
|
9
|
+
import {
|
|
10
|
+
AUDIO_NOTIFICATION_EVENTS,
|
|
11
|
+
AUDIO_NOTIFICATION_FLAG_NAMES,
|
|
12
|
+
type AudioNotificationConfig,
|
|
13
|
+
type AudioNotificationEvent,
|
|
14
|
+
type AudioPathValidationResult,
|
|
15
|
+
type EffectiveAudioNotificationPath,
|
|
16
|
+
type GlobalAudioNotificationSettings,
|
|
17
|
+
type ResolvedSoundFile,
|
|
18
|
+
} from "./audio-notification-types.js";
|
|
19
|
+
|
|
20
|
+
const SUPPORTED_AUDIO_EXTENSIONS = new Set([".mp3", ".wav"]);
|
|
21
|
+
|
|
22
|
+
type ValidationMode = "startup-flag" | "global-save" | "global-runtime";
|
|
23
|
+
|
|
24
|
+
function normalizeConfiguredValue(value: unknown): string | undefined {
|
|
25
|
+
if (typeof value !== "string") return undefined;
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function expandHomeDir(inputPath: string): string {
|
|
31
|
+
if (inputPath === "~") return os.homedir();
|
|
32
|
+
if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
|
|
33
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
34
|
+
}
|
|
35
|
+
return inputPath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasSupportedExtension(filePath: string): boolean {
|
|
39
|
+
return SUPPORTED_AUDIO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolvePathForMode(
|
|
43
|
+
inputPath: string,
|
|
44
|
+
cwd: string,
|
|
45
|
+
mode: ValidationMode,
|
|
46
|
+
): string | undefined {
|
|
47
|
+
const expandedPath = expandHomeDir(inputPath);
|
|
48
|
+
if (path.isAbsolute(expandedPath)) {
|
|
49
|
+
return path.normalize(expandedPath);
|
|
50
|
+
}
|
|
51
|
+
if (mode === "global-runtime") {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return path.resolve(cwd, expandedPath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function checkReadableFile(
|
|
58
|
+
resolvedPath: string,
|
|
59
|
+
): Promise<AudioPathValidationResult> {
|
|
60
|
+
if (!hasSupportedExtension(resolvedPath)) {
|
|
61
|
+
return { ok: false, reason: "unsupported-extension" };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const fileInfo = await stat(resolvedPath);
|
|
65
|
+
if (!fileInfo.isFile()) {
|
|
66
|
+
return { ok: false, reason: "not-file" };
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
return { ok: false, reason: "missing" };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await access(resolvedPath, constants.R_OK);
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: false, reason: "unreadable" };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
inputPath: resolvedPath,
|
|
79
|
+
resolvedPath,
|
|
80
|
+
persistedPath: resolvedPath,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function validateAudioNotificationPath(
|
|
85
|
+
inputPath: string | undefined,
|
|
86
|
+
options: { cwd: string; mode: ValidationMode },
|
|
87
|
+
): Promise<AudioPathValidationResult> {
|
|
88
|
+
const normalizedPath = normalizeConfiguredValue(inputPath);
|
|
89
|
+
if (normalizedPath === undefined) {
|
|
90
|
+
return { ok: false, reason: "unconfigured" };
|
|
91
|
+
}
|
|
92
|
+
const resolvedPath = resolvePathForMode(
|
|
93
|
+
normalizedPath,
|
|
94
|
+
options.cwd,
|
|
95
|
+
options.mode,
|
|
96
|
+
);
|
|
97
|
+
if (resolvedPath === undefined) {
|
|
98
|
+
return { ok: false, reason: "relative-global-path" };
|
|
99
|
+
}
|
|
100
|
+
const fileCheck = await checkReadableFile(resolvedPath);
|
|
101
|
+
if (!fileCheck.ok) {
|
|
102
|
+
return fileCheck;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
inputPath: normalizedPath,
|
|
107
|
+
resolvedPath,
|
|
108
|
+
persistedPath:
|
|
109
|
+
options.mode === "startup-flag" ? normalizedPath : resolvedPath,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getStartupFlagConfig(pi: ExtensionAPI): AudioNotificationConfig {
|
|
114
|
+
const config: AudioNotificationConfig = {};
|
|
115
|
+
for (const event of AUDIO_NOTIFICATION_EVENTS) {
|
|
116
|
+
const value = normalizeConfiguredValue(
|
|
117
|
+
pi.getFlag(AUDIO_NOTIFICATION_FLAG_NAMES[event]),
|
|
118
|
+
);
|
|
119
|
+
if (value !== undefined) {
|
|
120
|
+
config[event] = value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return config;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function registerAudioNotificationFlags(pi: ExtensionAPI): void {
|
|
127
|
+
for (const event of AUDIO_NOTIFICATION_EVENTS) {
|
|
128
|
+
pi.registerFlag(AUDIO_NOTIFICATION_FLAG_NAMES[event], {
|
|
129
|
+
description: `Path to the ${event} notification sound (.mp3 or .wav)`,
|
|
130
|
+
type: "string",
|
|
131
|
+
default: "",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getEffectiveAudioNotificationPath(
|
|
137
|
+
pi: ExtensionAPI,
|
|
138
|
+
globalSettings: GlobalAudioNotificationSettings,
|
|
139
|
+
event: AudioNotificationEvent,
|
|
140
|
+
): EffectiveAudioNotificationPath {
|
|
141
|
+
const startupFlags = getStartupFlagConfig(pi);
|
|
142
|
+
const startupPath = startupFlags[event];
|
|
143
|
+
if (startupPath !== undefined) {
|
|
144
|
+
return { source: "startup-flag", path: startupPath };
|
|
145
|
+
}
|
|
146
|
+
const globalPath = normalizeConfiguredValue(globalSettings.sounds[event]);
|
|
147
|
+
if (globalPath !== undefined) {
|
|
148
|
+
return { source: "global", path: globalPath };
|
|
149
|
+
}
|
|
150
|
+
return { source: "unconfigured" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function resolveAudioNotificationSound(
|
|
154
|
+
pi: ExtensionAPI,
|
|
155
|
+
ctx: ExtensionContext,
|
|
156
|
+
event: AudioNotificationEvent,
|
|
157
|
+
settingsStore: AudioSoundSettingsStore,
|
|
158
|
+
): Promise<ResolvedSoundFile> {
|
|
159
|
+
const globalSettings = await settingsStore.load();
|
|
160
|
+
const effectivePath = getEffectiveAudioNotificationPath(
|
|
161
|
+
pi,
|
|
162
|
+
globalSettings,
|
|
163
|
+
event,
|
|
164
|
+
);
|
|
165
|
+
if (effectivePath.source === "unconfigured") {
|
|
166
|
+
return { ok: false, event, reason: "unconfigured", source: "unconfigured" };
|
|
167
|
+
}
|
|
168
|
+
const validationMode: ValidationMode =
|
|
169
|
+
effectivePath.source === "startup-flag" ? "startup-flag" : "global-runtime";
|
|
170
|
+
const validatedPath = await validateAudioNotificationPath(
|
|
171
|
+
effectivePath.path,
|
|
172
|
+
{
|
|
173
|
+
cwd: ctx.cwd,
|
|
174
|
+
mode: validationMode,
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
if (!validatedPath.ok) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
event,
|
|
181
|
+
reason:
|
|
182
|
+
validatedPath.reason === "unsupported-extension"
|
|
183
|
+
? "unsupported-extension"
|
|
184
|
+
: "missing-or-unreadable",
|
|
185
|
+
source: effectivePath.source,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
event,
|
|
191
|
+
path: validatedPath.resolvedPath,
|
|
192
|
+
source: effectivePath.source,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { AudioSoundSettingsStore } from "./audio-sound-settings.js";
|
|
6
|
+
import { resolveAudioNotificationSound } from "./audio-notification-config.js";
|
|
7
|
+
import type { AudioNotificationEvent } from "./audio-notification-types.js";
|
|
8
|
+
import type { AudioPlaybackAdapter } from "./audio-playback.js";
|
|
9
|
+
|
|
10
|
+
export type AudioNotificationService = {
|
|
11
|
+
notify(event: AudioNotificationEvent, ctx: ExtensionContext): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class DefaultAudioNotificationService implements AudioNotificationService {
|
|
15
|
+
private readonly inFlightEvents = new Set<AudioNotificationEvent>();
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly pi: ExtensionAPI,
|
|
19
|
+
private readonly playbackAdapter: AudioPlaybackAdapter,
|
|
20
|
+
private readonly settingsStore: AudioSoundSettingsStore,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
notify(event: AudioNotificationEvent, ctx: ExtensionContext): void {
|
|
24
|
+
if (this.inFlightEvents.has(event)) return;
|
|
25
|
+
this.inFlightEvents.add(event);
|
|
26
|
+
void this.play(event, ctx)
|
|
27
|
+
.catch(() => undefined)
|
|
28
|
+
.finally(() => {
|
|
29
|
+
this.inFlightEvents.delete(event);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private async play(
|
|
34
|
+
event: AudioNotificationEvent,
|
|
35
|
+
ctx: ExtensionContext,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const resolvedSound = await resolveAudioNotificationSound(
|
|
38
|
+
this.pi,
|
|
39
|
+
ctx,
|
|
40
|
+
event,
|
|
41
|
+
this.settingsStore,
|
|
42
|
+
);
|
|
43
|
+
if (!resolvedSound.ok) return;
|
|
44
|
+
await this.playbackAdapter.play(resolvedSound.path);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createAudioNotificationService(
|
|
49
|
+
pi: ExtensionAPI,
|
|
50
|
+
playbackAdapter: AudioPlaybackAdapter,
|
|
51
|
+
settingsStore: AudioSoundSettingsStore,
|
|
52
|
+
): AudioNotificationService {
|
|
53
|
+
return new DefaultAudioNotificationService(
|
|
54
|
+
pi,
|
|
55
|
+
playbackAdapter,
|
|
56
|
+
settingsStore,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const AUDIO_NOTIFICATION_EVENTS = [
|
|
2
|
+
"completion",
|
|
3
|
+
"error",
|
|
4
|
+
"permission",
|
|
5
|
+
"question",
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
export type AudioNotificationEvent = (typeof AUDIO_NOTIFICATION_EVENTS)[number];
|
|
9
|
+
|
|
10
|
+
export const AUDIO_NOTIFICATION_FLAG_NAMES: Record<
|
|
11
|
+
AudioNotificationEvent,
|
|
12
|
+
string
|
|
13
|
+
> = {
|
|
14
|
+
completion: "osdy-pi-sound-completion",
|
|
15
|
+
error: "osdy-pi-sound-error",
|
|
16
|
+
permission: "osdy-pi-sound-permission",
|
|
17
|
+
question: "osdy-pi-sound-question",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type AudioNotificationConfig = Partial<
|
|
21
|
+
Record<AudioNotificationEvent, string>
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
export type GlobalAudioNotificationSettings = {
|
|
25
|
+
version: 1;
|
|
26
|
+
sounds: AudioNotificationConfig;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type AudioConfigSource = "startup-flag" | "global" | "unconfigured";
|
|
30
|
+
|
|
31
|
+
export type ResolvedSoundFile =
|
|
32
|
+
| {
|
|
33
|
+
ok: true;
|
|
34
|
+
event: AudioNotificationEvent;
|
|
35
|
+
path: string;
|
|
36
|
+
source: Exclude<AudioConfigSource, "unconfigured">;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
ok: false;
|
|
40
|
+
event: AudioNotificationEvent;
|
|
41
|
+
reason:
|
|
42
|
+
| "unconfigured"
|
|
43
|
+
| "unsupported-extension"
|
|
44
|
+
| "missing-or-unreadable";
|
|
45
|
+
source: AudioConfigSource;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type AudioPathValidationReason =
|
|
49
|
+
| "unconfigured"
|
|
50
|
+
| "unsupported-extension"
|
|
51
|
+
| "missing"
|
|
52
|
+
| "unreadable"
|
|
53
|
+
| "not-file"
|
|
54
|
+
| "relative-global-path";
|
|
55
|
+
|
|
56
|
+
export type AudioPathValidationResult =
|
|
57
|
+
| {
|
|
58
|
+
ok: true;
|
|
59
|
+
inputPath: string;
|
|
60
|
+
resolvedPath: string;
|
|
61
|
+
persistedPath: string;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
ok: false;
|
|
65
|
+
reason: AudioPathValidationReason;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type EffectiveAudioNotificationPath =
|
|
69
|
+
| {
|
|
70
|
+
source: Exclude<AudioConfigSource, "unconfigured">;
|
|
71
|
+
path: string;
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
source: "unconfigured";
|
|
75
|
+
path?: undefined;
|
|
76
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { AudioPlaybackAdapter } from "./audio-playback.js";
|
|
3
|
+
|
|
4
|
+
function runProcess(command: string, args: string[]): Promise<void> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(command, args, {
|
|
7
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
let stderr = "";
|
|
11
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
12
|
+
stderr += chunk.toString();
|
|
13
|
+
});
|
|
14
|
+
child.on("error", reject);
|
|
15
|
+
child.on("close", (code) => {
|
|
16
|
+
if (code === 0) {
|
|
17
|
+
resolve();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
reject(
|
|
21
|
+
new Error(
|
|
22
|
+
stderr.trim() || `${command} exited with code ${code ?? "unknown"}`,
|
|
23
|
+
),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createMacOsAudioPlaybackAdapter(): AudioPlaybackAdapter {
|
|
30
|
+
return {
|
|
31
|
+
platform: "darwin",
|
|
32
|
+
play(soundPath: string): Promise<void> {
|
|
33
|
+
return runProcess("afplay", [soundPath]);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { AudioPlaybackAdapter } from "./audio-playback.js";
|
|
3
|
+
|
|
4
|
+
const WINDOWS_AUDIO_TIMEOUT_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
const WINDOWS_AUDIO_SCRIPT = [
|
|
7
|
+
"$path = $env:OSDY_PI_SOUND_PATH",
|
|
8
|
+
'if ([string]::IsNullOrWhiteSpace($path)) { throw "Missing sound path" }',
|
|
9
|
+
"$player = New-Object -ComObject WMPlayer.OCX",
|
|
10
|
+
"$player.settings.volume = 100",
|
|
11
|
+
"$player.URL = $path",
|
|
12
|
+
"$player.controls.play()",
|
|
13
|
+
"while ($true) {",
|
|
14
|
+
" $state = $player.playState",
|
|
15
|
+
" if ($state -eq 1 -or $state -eq 8 -or $state -eq 10) { break }",
|
|
16
|
+
" Start-Sleep -Milliseconds 100",
|
|
17
|
+
"}",
|
|
18
|
+
].join("\n");
|
|
19
|
+
|
|
20
|
+
function encodePowerShellScript(script: string): string {
|
|
21
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function runPowerShell(script: string, soundPath: string): Promise<void> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const child = spawn(
|
|
27
|
+
"powershell.exe",
|
|
28
|
+
[
|
|
29
|
+
"-NoProfile",
|
|
30
|
+
"-NonInteractive",
|
|
31
|
+
"-ExecutionPolicy",
|
|
32
|
+
"Bypass",
|
|
33
|
+
"-EncodedCommand",
|
|
34
|
+
encodePowerShellScript(script),
|
|
35
|
+
],
|
|
36
|
+
{
|
|
37
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
38
|
+
env: { ...process.env, OSDY_PI_SOUND_PATH: soundPath },
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
let stderr = "";
|
|
43
|
+
let settled = false;
|
|
44
|
+
const timeout = setTimeout(() => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
child.kill();
|
|
48
|
+
reject(new Error("powershell audio playback timed out"));
|
|
49
|
+
}, WINDOWS_AUDIO_TIMEOUT_MS);
|
|
50
|
+
const finish = (callback: () => void) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
callback();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
58
|
+
stderr += chunk.toString();
|
|
59
|
+
});
|
|
60
|
+
child.on("error", (error) => {
|
|
61
|
+
finish(() => reject(error));
|
|
62
|
+
});
|
|
63
|
+
child.on("close", (code) => {
|
|
64
|
+
finish(() => {
|
|
65
|
+
if (code === 0) {
|
|
66
|
+
resolve();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
reject(
|
|
70
|
+
new Error(
|
|
71
|
+
stderr.trim() || `powershell exited with code ${code ?? "unknown"}`,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createWindowsAudioPlaybackAdapter(): AudioPlaybackAdapter {
|
|
80
|
+
return {
|
|
81
|
+
platform: "win32",
|
|
82
|
+
play(soundPath: string): Promise<void> {
|
|
83
|
+
return runPowerShell(WINDOWS_AUDIO_SCRIPT, soundPath);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createMacOsAudioPlaybackAdapter } from "./audio-playback-macos.js";
|
|
2
|
+
import { createUnsupportedAudioPlaybackAdapter } from "./audio-playback-unsupported.js";
|
|
3
|
+
import { createWindowsAudioPlaybackAdapter } from "./audio-playback-windows.js";
|
|
4
|
+
|
|
5
|
+
export type AudioPlaybackAdapter = {
|
|
6
|
+
readonly platform: "darwin" | "win32" | "unsupported";
|
|
7
|
+
play(path: string): Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createAudioPlaybackAdapter(): AudioPlaybackAdapter {
|
|
11
|
+
if (process.platform === "darwin") {
|
|
12
|
+
return createMacOsAudioPlaybackAdapter();
|
|
13
|
+
}
|
|
14
|
+
if (process.platform === "win32") {
|
|
15
|
+
return createWindowsAudioPlaybackAdapter();
|
|
16
|
+
}
|
|
17
|
+
return createUnsupportedAudioPlaybackAdapter();
|
|
18
|
+
}
|