ink-framework-audio 0.1.0-alpha.10 → 0.1.0-alpha.13
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/package.json +5 -4
- package/src/capture-internal.ts +10 -19
- package/src/index.ts +92 -19
- package/src/microphone.ts +1 -1
- package/src/recording.ts +1 -1
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-framework-audio",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.13",
|
|
4
4
|
"exports": {
|
|
5
5
|
".": "./src/index.ts",
|
|
6
6
|
"./microphone": "./src/microphone.ts",
|
|
7
7
|
"./recording": "./src/recording.ts"
|
|
8
8
|
},
|
|
9
9
|
"peerDependencies": {
|
|
10
|
-
"@ink/files": "npm:ink-framework-files@0.1.0-alpha.
|
|
11
|
-
"ink": "npm:ink-framework@0.1.0-alpha.
|
|
10
|
+
"@ink/files": "npm:ink-framework-files@0.1.0-alpha.13",
|
|
11
|
+
"ink": "npm:ink-framework@0.1.0-alpha.13",
|
|
12
12
|
"react": "19.2.8"
|
|
13
13
|
},
|
|
14
14
|
"license": "MIT",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"src",
|
|
22
22
|
"*.json",
|
|
23
|
-
"*.ts"
|
|
23
|
+
"*.ts",
|
|
24
|
+
"*.md"
|
|
24
25
|
]
|
|
25
26
|
}
|
package/src/capture-internal.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createElement, useEffect, useMemo,
|
|
1
|
+
import { createElement, useEffect, useMemo, useState } from "ink/internal/react";
|
|
2
2
|
import type { Snapshot, SnapshotSource } from "ink";
|
|
3
3
|
import { NativeError } from "ink/native";
|
|
4
|
-
import {
|
|
4
|
+
import { useNativeController } from "ink/internal/controller";
|
|
5
5
|
|
|
6
6
|
const displayController = Symbol("captureController");
|
|
7
7
|
export type CaptureDisplay = { readonly [displayController]: number | null };
|
|
@@ -48,7 +48,7 @@ export function useCapture<State extends { status: string; error: Error | null }
|
|
|
48
48
|
) {
|
|
49
49
|
const [state, setState] = useState(() => selectStatus(initial));
|
|
50
50
|
const [id, setId] = useState<number | null>(null);
|
|
51
|
-
const controller =
|
|
51
|
+
const controller = useNativeController("audio", "audio capture");
|
|
52
52
|
const channel = useMemo(() => {
|
|
53
53
|
let snapshot: Snapshot<State> = { status: "loading" };
|
|
54
54
|
const listeners = new Set<() => void>();
|
|
@@ -75,7 +75,7 @@ export function useCapture<State extends { status: string; error: Error | null }
|
|
|
75
75
|
},
|
|
76
76
|
};
|
|
77
77
|
return { source, publish, observe };
|
|
78
|
-
}, []);
|
|
78
|
+
}, [controller]);
|
|
79
79
|
useEffect(() => {
|
|
80
80
|
setState(selectStatus(initial));
|
|
81
81
|
setId(null);
|
|
@@ -92,27 +92,18 @@ export function useCapture<State extends { status: string; error: Error | null }
|
|
|
92
92
|
setState({ ...selectStatus(initial), status: "error", error: failure });
|
|
93
93
|
channel.publish({ status: "error", error: failure });
|
|
94
94
|
};
|
|
95
|
-
|
|
96
|
-
if (controller.current !== attachment) return;
|
|
95
|
+
return controller.attach({ kind, config: referenceHz === undefined ? {} : { referenceHz } }, value => {
|
|
97
96
|
try { publish(decode(value)); }
|
|
98
97
|
catch (error) { fail(error); }
|
|
98
|
+
}, fail, id => {
|
|
99
|
+
setId(id);
|
|
100
|
+
channel.observe();
|
|
99
101
|
});
|
|
100
|
-
|
|
101
|
-
void attachment.ready.then(() => {
|
|
102
|
-
if (controller.current === attachment) {
|
|
103
|
-
setId(attachment.id);
|
|
104
|
-
channel.observe();
|
|
105
|
-
}
|
|
106
|
-
}, error => { if (controller.current === attachment) fail(error); });
|
|
107
|
-
return () => {
|
|
108
|
-
controller.current = null;
|
|
109
|
-
void attachment.dispose().catch(error => console.error("Could not release audio capture", error));
|
|
110
|
-
};
|
|
111
|
-
}, [kind, initial, decode, selectStatus, referenceHz, channel]);
|
|
102
|
+
}, [kind, initial, decode, selectStatus, referenceHz, channel, controller]);
|
|
112
103
|
const call = useMemo(() => async (operation: string) => {
|
|
113
104
|
if (!controller.current) throw new NativeError("unavailable", "Audio capture is not attached");
|
|
114
105
|
return controller.current.call(operation);
|
|
115
|
-
}, []);
|
|
106
|
+
}, [controller]);
|
|
116
107
|
const display = useMemo(() => ({ [displayController]: id }), [id]);
|
|
117
108
|
return { state, ready: id !== null, call, display, measurements: channel.source };
|
|
118
109
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "ink/internal/react";
|
|
2
|
+
import { useNativeController } from "ink/internal/controller";
|
|
3
3
|
import { NativeError } from "ink/native";
|
|
4
4
|
import { playbackController } from "ink/internal/playback";
|
|
5
5
|
|
|
@@ -14,6 +14,18 @@ export interface AudioItem {
|
|
|
14
14
|
}
|
|
15
15
|
export type SourceReplacement = { prepare?: boolean } & ({ position?: number; offset?: never } | { offset: number; position?: never });
|
|
16
16
|
|
|
17
|
+
export interface PlayerOptions {
|
|
18
|
+
session?: string;
|
|
19
|
+
mode?: "attached" | "detached";
|
|
20
|
+
usage?: "music" | "speech";
|
|
21
|
+
/** Reuse HTTP(S) audio in a shared 256 MiB disc cache. Off by default. */
|
|
22
|
+
cache?: boolean;
|
|
23
|
+
/** Milliseconds to buffer for the next queued item. Off by default. */
|
|
24
|
+
preloadNext?: number;
|
|
25
|
+
/** Milliseconds of audio to buffer before streaming starts. Defaults to 1000. */
|
|
26
|
+
startupBuffer?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export interface PlayerState {
|
|
18
30
|
ready: boolean;
|
|
19
31
|
playing: boolean;
|
|
@@ -21,17 +33,48 @@ export interface PlayerState {
|
|
|
21
33
|
buffering: boolean;
|
|
22
34
|
loading: boolean;
|
|
23
35
|
ended: boolean;
|
|
36
|
+
queue: readonly AudioItem[];
|
|
24
37
|
current: AudioItem | null;
|
|
25
38
|
index: number;
|
|
26
39
|
position: number;
|
|
27
40
|
duration: number;
|
|
28
41
|
speed: number;
|
|
42
|
+
shuffle: boolean;
|
|
43
|
+
repeat: "off" | "queue" | "track";
|
|
29
44
|
skipSilence: boolean;
|
|
30
45
|
voiceBoost: boolean;
|
|
31
46
|
silenceSaved: number;
|
|
32
47
|
error: Error | null;
|
|
33
48
|
}
|
|
34
|
-
const initial: PlayerState = { ready: false, playing: false, playWhenReady: false, buffering: false, loading: false, ended: false, current: null, index: -1, position: 0, duration: 0, speed: 1, skipSilence: false, voiceBoost: false, silenceSaved: 0, error: null };
|
|
49
|
+
const initial: PlayerState = { ready: false, playing: false, playWhenReady: false, buffering: false, loading: false, ended: false, queue: [], current: null, index: -1, position: 0, duration: 0, speed: 1, shuffle: false, repeat: "off", skipSilence: false, voiceBoost: false, silenceSaved: 0, error: null };
|
|
50
|
+
|
|
51
|
+
function decodeItem(value: unknown): AudioItem {
|
|
52
|
+
if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid audio queue item");
|
|
53
|
+
const text = (name: string): string => {
|
|
54
|
+
const result: unknown = Reflect.get(value, name);
|
|
55
|
+
if (typeof result !== "string") throw new NativeError("protocol", `Invalid audio item ${name}`);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
const duration: unknown = Reflect.get(value, "duration");
|
|
59
|
+
if (duration !== undefined && (typeof duration !== "number" || !Number.isFinite(duration) || duration < 0)) {
|
|
60
|
+
throw new NativeError("protocol", "Invalid audio item duration");
|
|
61
|
+
}
|
|
62
|
+
return { id: text("id"), src: text("src"), title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateId(id: string) {
|
|
66
|
+
if (typeof id !== "string" || !id.trim()) throw new TypeError("Audio item IDs must not be blank");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateQueue(items: readonly AudioItem[]) {
|
|
70
|
+
if (items.length === 0) throw new RangeError("Audio queues cannot be empty");
|
|
71
|
+
const ids = new Set<string>();
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
validateId(item.id);
|
|
74
|
+
if (ids.has(item.id)) throw new TypeError("Audio queue item IDs must be unique");
|
|
75
|
+
ids.add(item.id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
35
78
|
|
|
36
79
|
function decode(value: unknown): PlayerState {
|
|
37
80
|
if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid player state");
|
|
@@ -64,6 +107,12 @@ function decode(value: unknown): PlayerState {
|
|
|
64
107
|
throw new NativeError("protocol", "Invalid player queue index");
|
|
65
108
|
}
|
|
66
109
|
const duration = number("durationMs");
|
|
110
|
+
const queue = field("queue");
|
|
111
|
+
const repeat = field("repeat");
|
|
112
|
+
if (repeat !== "off" && repeat !== "queue" && repeat !== "track") throw new NativeError("protocol", "Invalid repeat mode");
|
|
113
|
+
const shuffle = field("shuffle");
|
|
114
|
+
if (typeof shuffle !== "boolean") throw new NativeError("protocol", "Invalid shuffle mode");
|
|
115
|
+
if (!Array.isArray(queue)) throw new NativeError("protocol", "Invalid audio queue");
|
|
67
116
|
return {
|
|
68
117
|
ready: field("ready") === true,
|
|
69
118
|
playing: status === "playing",
|
|
@@ -71,37 +120,38 @@ function decode(value: unknown): PlayerState {
|
|
|
71
120
|
buffering: status === "loading",
|
|
72
121
|
loading: field("loading") === true,
|
|
73
122
|
ended: status === "ended",
|
|
123
|
+
queue: queue.map(decodeItem),
|
|
74
124
|
index,
|
|
75
125
|
current: src ? { id: text("id"), src, title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration } : null,
|
|
76
126
|
position: number("positionMs"), duration, speed: number("speed"),
|
|
127
|
+
shuffle, repeat,
|
|
77
128
|
skipSilence: field("skipSilence") === true, voiceBoost: field("voiceBoost") === true,
|
|
78
129
|
silenceSaved: number("silenceSavedMs"), error,
|
|
79
130
|
};
|
|
80
131
|
}
|
|
81
132
|
|
|
82
|
-
export function usePlayer(options:
|
|
83
|
-
const { session = "main", mode = "attached", usage = "music" } = options;
|
|
133
|
+
export function usePlayer(options: PlayerOptions = {}) {
|
|
134
|
+
const { session = "main", mode = "attached", usage = "music", cache = false, preloadNext = 0, startupBuffer = 1000 } = options;
|
|
84
135
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(session)) throw new TypeError("Audio session names must be 1–64 letters, numbers, dots, underscores or hyphens");
|
|
136
|
+
if (!Number.isSafeInteger(preloadNext) || preloadNext < 0 || preloadNext > 30_000) throw new RangeError("Next-item preloading must be between 0 and 30000 milliseconds");
|
|
137
|
+
if (!Number.isSafeInteger(startupBuffer) || startupBuffer < 0 || startupBuffer > 30_000) throw new RangeError("Startup buffering must be between 0 and 30000 milliseconds");
|
|
85
138
|
const [state, setState] = useState(initial);
|
|
86
139
|
const current = useRef(initial);
|
|
87
|
-
const controller =
|
|
140
|
+
const controller = useNativeController("audio", "audio player");
|
|
88
141
|
useEffect(() => {
|
|
89
142
|
const publish = (next: PlayerState) => { current.current = next; setState(next); };
|
|
90
143
|
publish(initial);
|
|
91
|
-
const
|
|
144
|
+
const detach = controller.attach({ kind: "player", config: { session, playback: mode, usage, cache, preloadNext, startupBuffer } }, value => {
|
|
92
145
|
try { publish(decode(value)); }
|
|
93
146
|
catch (error) { publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) }); }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
void attachment.ready.catch(error => {
|
|
97
|
-
if (controller.current === attachment) publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) });
|
|
147
|
+
}, error => {
|
|
148
|
+
publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) });
|
|
98
149
|
});
|
|
99
150
|
return () => {
|
|
100
|
-
|
|
151
|
+
detach();
|
|
101
152
|
current.current = initial;
|
|
102
|
-
void attachment.dispose().catch(error => console.error("Could not release audio player", error));
|
|
103
153
|
};
|
|
104
|
-
}, [session, mode, usage]);
|
|
154
|
+
}, [session, mode, usage, cache, preloadNext, startupBuffer, controller]);
|
|
105
155
|
const commands = useMemo(() => {
|
|
106
156
|
const call = async (operation: string, payload: unknown = {}) => {
|
|
107
157
|
if (!controller.current || !current.current.ready) throw new NativeError("unavailable", "Audio player is not ready");
|
|
@@ -126,15 +176,38 @@ export function usePlayer(options: { session?: string; mode?: "attached" | "deta
|
|
|
126
176
|
}
|
|
127
177
|
return call("replaceSource", { id, src, ...options });
|
|
128
178
|
},
|
|
129
|
-
setQueue(items: readonly AudioItem[], options: { startIndex?: number; startPosition?: number; prepare?: boolean } = {}) {
|
|
179
|
+
async setQueue(items: readonly AudioItem[], options: { startIndex?: number; startPosition?: number; prepare?: boolean } = {}) {
|
|
180
|
+
validateQueue(items);
|
|
130
181
|
const startPosition = options.startPosition ?? 0;
|
|
131
|
-
if (!Number.isFinite(startPosition) || startPosition < 0)
|
|
132
|
-
|
|
182
|
+
if (!Number.isFinite(startPosition) || startPosition < 0) throw new RangeError("Audio position must be a non-negative number of milliseconds");
|
|
183
|
+
await call("setQueue", { items, startIndex: options.startIndex ?? 0, startPosition, prepare: options.prepare ?? true });
|
|
184
|
+
},
|
|
185
|
+
async add(item: AudioItem, options: { at?: number | "next" } = {}) {
|
|
186
|
+
validateId(item.id);
|
|
187
|
+
if (options.at !== undefined && options.at !== "next" && (!Number.isSafeInteger(options.at) || options.at < 0)) {
|
|
188
|
+
throw new RangeError("Audio insertion index must be a non-negative integer");
|
|
189
|
+
}
|
|
190
|
+
await call("add", { item, ...options });
|
|
191
|
+
},
|
|
192
|
+
remove: (id: string) => call("remove", { id }),
|
|
193
|
+
async move(id: string, index: number) {
|
|
194
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new RangeError("Audio destination index must be a non-negative integer");
|
|
195
|
+
await call("move", { id, index });
|
|
196
|
+
},
|
|
197
|
+
async replace(id: string, item: AudioItem) {
|
|
198
|
+
validateId(item.id);
|
|
199
|
+
await call("replace", { id, item });
|
|
200
|
+
},
|
|
201
|
+
async play(id?: string) {
|
|
202
|
+
if (id !== undefined) validateId(id);
|
|
203
|
+
await call(id === undefined ? "play" : "playItem", { id });
|
|
133
204
|
},
|
|
134
|
-
|
|
205
|
+
prepare: () => call("prepare"),
|
|
135
206
|
pause: () => call("pause"),
|
|
136
207
|
toggle: () => call("toggle"),
|
|
137
208
|
stop: () => call("stop"),
|
|
209
|
+
setShuffle: (enabled: boolean) => call("setShuffle", { value: enabled }),
|
|
210
|
+
setRepeat: (mode: PlayerState["repeat"]) => call("setRepeat", { value: mode }),
|
|
138
211
|
setSpeed(speed: number) {
|
|
139
212
|
if (!Number.isFinite(speed) || speed < 0.25 || speed > 4) return Promise.reject(new RangeError("Playback speed must be between 0.25 and 4"));
|
|
140
213
|
return call("setSpeed", { value: speed });
|
|
@@ -148,6 +221,6 @@ export function usePlayer(options: { session?: string; mode?: "attached" | "deta
|
|
|
148
221
|
return call("seekTo", { value: position });
|
|
149
222
|
},
|
|
150
223
|
};
|
|
151
|
-
}, []);
|
|
224
|
+
}, [controller]);
|
|
152
225
|
return { state, [playbackController]: controller.current?.id, ...commands };
|
|
153
226
|
}
|
package/src/microphone.ts
CHANGED
package/src/recording.ts
CHANGED