osdy-pi 0.1.4 → 0.1.6

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 CHANGED
@@ -10,15 +10,24 @@ Visit the Osdy landing page: [landing-osdy.vercel.app](https://landing-osdy.verc
10
10
 
11
11
  - **Dark theme:** `osdy-pi-dark`, enabled by default when the package starts.
12
12
  - **Light theme:** `osdy-pi-light`, with the same Osdy palette adapted for light terminals.
13
- - **Custom header:** centered Osdy-Pi ASCII branding with responsive status metadata.
13
+ - **Simple theme:** `osdy-pi-simple`, a blue/red/slate console theme for the full Pi interface.
14
+ - **Custom header:** two selectable header styles with responsive status metadata: `osdy-theme` (default) and `classic`.
14
15
  - **Custom editor:** full-width framed input area with model, thinking, token, cost, and context status.
15
- - **Clean layout:** the built-in footer/working row is hidden while Osdy Pi is enabled to avoid duplicated UI.
16
+ - **Custom working indicator:** a dedicated working widget/spinner appears above the text box, outside the editor frame.
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.
16
19
 
17
20
  <img width="1280" height="433" alt="image" src="https://github.com/user-attachments/assets/20c7624d-9ad8-4494-97fb-6b6d81aaf328" />
18
21
 
19
22
  ## Install in Pi
20
23
 
21
- Install the package directly from GitHub:
24
+ Install the published package from npm:
25
+
26
+ ```bash
27
+ pi install npm:osdy-pi
28
+ ```
29
+
30
+ You can also install it directly from GitHub:
22
31
 
23
32
  ```bash
24
33
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
@@ -45,6 +54,7 @@ Then select one of these theme names:
45
54
  ```text
46
55
  osdy-pi-dark
47
56
  osdy-pi-light
57
+ osdy-pi-simple
48
58
  ```
49
59
 
50
60
  You can also set it in your Pi `settings.json`:
@@ -55,7 +65,7 @@ You can also set it in your Pi `settings.json`:
55
65
  }
56
66
  ```
57
67
 
58
- Use `osdy-pi-light` instead if you prefer the light version.
68
+ Use `osdy-pi-light` if you prefer the light version, or `osdy-pi-simple` if you want the blue/red/slate palette across the whole console.
59
69
 
60
70
  ## Commands
61
71
 
@@ -65,11 +75,88 @@ Osdy Pi includes a small command group:
65
75
  /osdy-pi enable
66
76
  /osdy-pi disable
67
77
  /osdy-pi status
78
+ /osdy-pi sound setup
79
+ /osdy-pi osdy-theme
80
+ /osdy-pi classic
81
+ /osdy-pi-osdy-theme
82
+ /osdy-pi-classic
68
83
  ```
69
84
 
70
85
  - `enable` applies the dark Osdy theme, custom header, custom editor, and clean layout.
71
86
  - `disable` restores Pi's built-in header, editor, footer, and working visibility, then switches back to the previous theme or `dark`.
72
- - `status` shows whether the Osdy Pi UI is currently enabled.
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.
89
+ - `osdy-theme` is the default OsdyTheme header with pink, cyan, and purple styling, plus the mascot glow on the right edge.
90
+ - `classic` keeps the previous classic header shape with the shared mascot.
91
+ - `/osdy-pi-osdy-theme` and `/osdy-pi-classic` are direct aliases.
92
+
93
+ After changing a local extension, run `/reload` or restart Pi so the updated commands are registered.
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.
73
160
 
74
161
  ## Local install
75
162
 
@@ -92,11 +179,24 @@ pi install /absolute/path/to/Osdy-Pi
92
179
  ```text
93
180
  themes/osdy-pi-dark.json
94
181
  themes/osdy-pi-light.json
182
+ themes/osdy-pi-simple.json
95
183
  extensions/osdy-pi.ts
184
+ extensions/osdy-pi/
96
185
  ```
97
186
 
187
+ `extensions/osdy-pi.ts` is the package entrypoint. The implementation lives in the modular `extensions/osdy-pi/` folder (runtime, UI, metrics, working controller, animation, border, and formatting helpers).
188
+
98
189
  The Pi manifest is declared in `package.json` through `pi.themes` and `pi.extensions`, so Pi can discover the themes and extension after installation.
99
190
 
191
+ ## Development
192
+
193
+ If you are working on the package locally, you can run:
194
+
195
+ ```bash
196
+ npm run typecheck
197
+ npm run lint
198
+ ```
199
+
100
200
  ## Uninstall or turn off
101
201
 
102
202
  To temporarily turn off the custom UI inside Pi:
@@ -1,5 +1,6 @@
1
1
  import { ANIMATION_ENABLED } from "./constants.js";
2
2
  import type { AnimationMode, SimpleTheme } from "./types.js";
3
+ import type { MascotTonePalette } from "./constants.js";
3
4
  import { positiveModulo } from "./utils.js";
4
5
 
5
6
  export type AsciiAnimationStyle = "static" | "animated";
@@ -57,3 +58,42 @@ export function animateAsciiLine(
57
58
  })
58
59
  .join("");
59
60
  }
61
+
62
+ export function animateAsciiLineWithToneMap(
63
+ line: string,
64
+ toneMap: string,
65
+ lineIndex: number,
66
+ frame: number,
67
+ theme: SimpleTheme,
68
+ palette: MascotTonePalette,
69
+ style: AsciiAnimationStyle,
70
+ ): string {
71
+ return Array.from(line)
72
+ .map((char, charIndex) => {
73
+ if (char === " ") return char;
74
+ const tone = toneMap[charIndex];
75
+ if (
76
+ tone !== "b" &&
77
+ tone !== "h" &&
78
+ tone !== "l" &&
79
+ tone !== "m" &&
80
+ tone !== "d" &&
81
+ tone !== "p" &&
82
+ tone !== "c" &&
83
+ tone !== "v"
84
+ ) {
85
+ return char;
86
+ }
87
+ const baseColor = palette[tone];
88
+ if (style === "static") return theme.fg(baseColor, char);
89
+ const wave = positiveModulo(charIndex + lineIndex * 2 - frame * 5, 44);
90
+ const highlightColor =
91
+ tone === "b" || tone === "h" || tone === "p" ? palette.h : palette.l;
92
+ const trailColor = tone === "d" ? palette.m : palette.b;
93
+ return theme.fg(
94
+ getAnimatedAsciiColor(wave, baseColor, highlightColor, trailColor),
95
+ char,
96
+ );
97
+ })
98
+ .join("");
99
+ }
@@ -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,8 @@
1
+ import type { AudioPlaybackAdapter } from "./audio-playback.js";
2
+
3
+ export function createUnsupportedAudioPlaybackAdapter(): AudioPlaybackAdapter {
4
+ return {
5
+ platform: "unsupported",
6
+ play: () => Promise.resolve(),
7
+ };
8
+ }