ink-framework-audio 0.1.0-alpha.12 → 0.1.0-alpha.14
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/decode.ts +75 -0
- package/src/index.ts +23 -84
- 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.14",
|
|
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.14",
|
|
11
|
+
"ink": "npm:ink-framework@0.1.0-alpha.14",
|
|
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/decode.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NativeError } from "ink/native";
|
|
2
|
+
import type { AudioItem, PlayerState } from "./index";
|
|
3
|
+
|
|
4
|
+
function decodeItem(value: unknown): AudioItem {
|
|
5
|
+
if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid audio queue item");
|
|
6
|
+
const text = (name: string): string => {
|
|
7
|
+
const result: unknown = Reflect.get(value, name);
|
|
8
|
+
if (typeof result !== "string") throw new NativeError("protocol", `Invalid audio item ${name}`);
|
|
9
|
+
return result;
|
|
10
|
+
};
|
|
11
|
+
const duration: unknown = Reflect.get(value, "duration");
|
|
12
|
+
if (duration !== undefined && (typeof duration !== "number" || !Number.isFinite(duration) || duration < 0)) {
|
|
13
|
+
throw new NativeError("protocol", "Invalid audio item duration");
|
|
14
|
+
}
|
|
15
|
+
return { id: text("id"), src: text("src"), title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration, usage: decodeUsage(Reflect.get(value, "usage")) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function decodeUsage(value: unknown): AudioItem["usage"] {
|
|
19
|
+
if (value === undefined || value === "music" || value === "speech") return value;
|
|
20
|
+
throw new NativeError("protocol", "Invalid audio item usage");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decodePlayerState(value: unknown): PlayerState {
|
|
24
|
+
if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid player state");
|
|
25
|
+
const field = (name: string): unknown => Reflect.get(value, name);
|
|
26
|
+
const text = (name: string) => {
|
|
27
|
+
const result = field(name);
|
|
28
|
+
if (typeof result !== "string") throw new NativeError("protocol", `Invalid player ${name}`);
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
const number = (name: string) => {
|
|
32
|
+
const result = field(name);
|
|
33
|
+
if (typeof result !== "number" || !Number.isFinite(result) || result < 0) throw new NativeError("protocol", `Invalid player ${name}`);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
const status = text("status");
|
|
37
|
+
if (!["idle", "loading", "playing", "paused", "ended", "error"].includes(status) || typeof field("ready") !== "boolean") {
|
|
38
|
+
throw new NativeError("protocol", "Invalid player status");
|
|
39
|
+
}
|
|
40
|
+
let error: Error | null = null;
|
|
41
|
+
if (status === "error") {
|
|
42
|
+
const detail = field("error");
|
|
43
|
+
if (typeof detail !== "object" || detail === null
|
|
44
|
+
|| !("message" in detail) || typeof detail.message !== "string"
|
|
45
|
+
|| !("kind" in detail) || typeof detail.kind !== "string") throw new NativeError("protocol", "Invalid player error");
|
|
46
|
+
error = new NativeError(detail.kind, detail.message, "retryable" in detail && detail.retryable === true);
|
|
47
|
+
}
|
|
48
|
+
const src = text("src");
|
|
49
|
+
const index = field("index");
|
|
50
|
+
if (typeof index !== "number" || !Number.isSafeInteger(index) || index < -1) {
|
|
51
|
+
throw new NativeError("protocol", "Invalid player queue index");
|
|
52
|
+
}
|
|
53
|
+
const duration = number("durationMs");
|
|
54
|
+
const queue = field("queue");
|
|
55
|
+
const repeat = field("repeat");
|
|
56
|
+
if (repeat !== "off" && repeat !== "queue" && repeat !== "track") throw new NativeError("protocol", "Invalid repeat mode");
|
|
57
|
+
const shuffle = field("shuffle");
|
|
58
|
+
if (typeof shuffle !== "boolean") throw new NativeError("protocol", "Invalid shuffle mode");
|
|
59
|
+
if (!Array.isArray(queue)) throw new NativeError("protocol", "Invalid audio queue");
|
|
60
|
+
return {
|
|
61
|
+
ready: field("ready") === true,
|
|
62
|
+
playing: status === "playing",
|
|
63
|
+
playWhenReady: field("playWhenReady") === true,
|
|
64
|
+
buffering: status === "loading",
|
|
65
|
+
loading: field("loading") === true,
|
|
66
|
+
ended: status === "ended",
|
|
67
|
+
queue: queue.map(decodeItem),
|
|
68
|
+
index,
|
|
69
|
+
current: src ? { id: text("id"), src, title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration, usage: decodeUsage(field("usage")) } : null,
|
|
70
|
+
position: number("positionMs"), duration, speed: number("speed"),
|
|
71
|
+
shuffle, repeat,
|
|
72
|
+
skipSilence: field("skipSilence") === true, voiceBoost: field("voiceBoost") === true,
|
|
73
|
+
silenceSaved: number("silenceSavedMs"), error,
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
import { decodePlayerState } from "./decode";
|
|
5
6
|
|
|
6
7
|
export interface AudioItem {
|
|
7
8
|
id: string;
|
|
@@ -11,6 +12,7 @@ export interface AudioItem {
|
|
|
11
12
|
album?: string;
|
|
12
13
|
artwork?: string;
|
|
13
14
|
duration?: number;
|
|
15
|
+
usage?: "music" | "speech";
|
|
14
16
|
}
|
|
15
17
|
export type SourceReplacement = { prepare?: boolean } & ({ position?: number; offset?: never } | { offset: number; position?: never });
|
|
16
18
|
|
|
@@ -20,6 +22,8 @@ export interface PlayerOptions {
|
|
|
20
22
|
usage?: "music" | "speech";
|
|
21
23
|
/** Reuse HTTP(S) audio in a shared 256 MiB disc cache. Off by default. */
|
|
22
24
|
cache?: boolean;
|
|
25
|
+
/** Allow hardware audio offload when effects and speed changes are inactive. Off by default. */
|
|
26
|
+
offload?: boolean;
|
|
23
27
|
/** Milliseconds to buffer for the next queued item. Off by default. */
|
|
24
28
|
preloadNext?: number;
|
|
25
29
|
/** Milliseconds of audio to buffer before streaming starts. Defaults to 1000. */
|
|
@@ -48,18 +52,9 @@ export interface PlayerState {
|
|
|
48
52
|
}
|
|
49
53
|
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
54
|
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
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 };
|
|
55
|
+
function validateItem(item: AudioItem) {
|
|
56
|
+
validateId(item.id);
|
|
57
|
+
if (item.usage !== undefined && item.usage !== "music" && item.usage !== "speech") throw new TypeError("Audio usage must be music or speech");
|
|
63
58
|
}
|
|
64
59
|
|
|
65
60
|
function validateId(id: string) {
|
|
@@ -70,91 +65,35 @@ function validateQueue(items: readonly AudioItem[]) {
|
|
|
70
65
|
if (items.length === 0) throw new RangeError("Audio queues cannot be empty");
|
|
71
66
|
const ids = new Set<string>();
|
|
72
67
|
for (const item of items) {
|
|
73
|
-
|
|
68
|
+
validateItem(item);
|
|
74
69
|
if (ids.has(item.id)) throw new TypeError("Audio queue item IDs must be unique");
|
|
75
70
|
ids.add(item.id);
|
|
76
71
|
}
|
|
77
72
|
}
|
|
78
73
|
|
|
79
|
-
function decode(value: unknown): PlayerState {
|
|
80
|
-
if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid player state");
|
|
81
|
-
const field = (name: string): unknown => Reflect.get(value, name);
|
|
82
|
-
const text = (name: string) => {
|
|
83
|
-
const result = field(name);
|
|
84
|
-
if (typeof result !== "string") throw new NativeError("protocol", `Invalid player ${name}`);
|
|
85
|
-
return result;
|
|
86
|
-
};
|
|
87
|
-
const number = (name: string) => {
|
|
88
|
-
const result = field(name);
|
|
89
|
-
if (typeof result !== "number" || !Number.isFinite(result) || result < 0) throw new NativeError("protocol", `Invalid player ${name}`);
|
|
90
|
-
return result;
|
|
91
|
-
};
|
|
92
|
-
const status = text("status");
|
|
93
|
-
if (!["idle", "loading", "playing", "paused", "ended", "error"].includes(status) || typeof field("ready") !== "boolean") {
|
|
94
|
-
throw new NativeError("protocol", "Invalid player status");
|
|
95
|
-
}
|
|
96
|
-
let error: Error | null = null;
|
|
97
|
-
if (status === "error") {
|
|
98
|
-
const detail = field("error");
|
|
99
|
-
if (typeof detail !== "object" || detail === null
|
|
100
|
-
|| !("message" in detail) || typeof detail.message !== "string"
|
|
101
|
-
|| !("kind" in detail) || typeof detail.kind !== "string") throw new NativeError("protocol", "Invalid player error");
|
|
102
|
-
error = new NativeError(detail.kind, detail.message, "retryable" in detail && detail.retryable === true);
|
|
103
|
-
}
|
|
104
|
-
const src = text("src");
|
|
105
|
-
const index = field("index");
|
|
106
|
-
if (typeof index !== "number" || !Number.isSafeInteger(index) || index < -1) {
|
|
107
|
-
throw new NativeError("protocol", "Invalid player queue index");
|
|
108
|
-
}
|
|
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");
|
|
116
|
-
return {
|
|
117
|
-
ready: field("ready") === true,
|
|
118
|
-
playing: status === "playing",
|
|
119
|
-
playWhenReady: field("playWhenReady") === true,
|
|
120
|
-
buffering: status === "loading",
|
|
121
|
-
loading: field("loading") === true,
|
|
122
|
-
ended: status === "ended",
|
|
123
|
-
queue: queue.map(decodeItem),
|
|
124
|
-
index,
|
|
125
|
-
current: src ? { id: text("id"), src, title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration } : null,
|
|
126
|
-
position: number("positionMs"), duration, speed: number("speed"),
|
|
127
|
-
shuffle, repeat,
|
|
128
|
-
skipSilence: field("skipSilence") === true, voiceBoost: field("voiceBoost") === true,
|
|
129
|
-
silenceSaved: number("silenceSavedMs"), error,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
74
|
export function usePlayer(options: PlayerOptions = {}) {
|
|
134
|
-
const { session = "main", mode = "attached", usage = "music", cache = false, preloadNext = 0, startupBuffer = 1000 } = options;
|
|
75
|
+
const { session = "main", mode = "attached", usage = "music", cache = false, offload = false, preloadNext = 0, startupBuffer = 1000 } = options;
|
|
135
76
|
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");
|
|
77
|
+
if (usage !== "music" && usage !== "speech") throw new TypeError("Audio usage must be music or speech");
|
|
136
78
|
if (!Number.isSafeInteger(preloadNext) || preloadNext < 0 || preloadNext > 30_000) throw new RangeError("Next-item preloading must be between 0 and 30000 milliseconds");
|
|
137
79
|
if (!Number.isSafeInteger(startupBuffer) || startupBuffer < 0 || startupBuffer > 30_000) throw new RangeError("Startup buffering must be between 0 and 30000 milliseconds");
|
|
138
80
|
const [state, setState] = useState(initial);
|
|
139
81
|
const current = useRef(initial);
|
|
140
|
-
const controller =
|
|
82
|
+
const controller = useNativeController("audio", "audio player");
|
|
141
83
|
useEffect(() => {
|
|
142
84
|
const publish = (next: PlayerState) => { current.current = next; setState(next); };
|
|
143
85
|
publish(initial);
|
|
144
|
-
const
|
|
145
|
-
try { publish(
|
|
86
|
+
const detach = controller.attach({ kind: "player", config: { session, playback: mode, usage, cache, offload, preloadNext, startupBuffer } }, value => {
|
|
87
|
+
try { publish(decodePlayerState(value)); }
|
|
146
88
|
catch (error) { publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) }); }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
void attachment.ready.catch(error => {
|
|
150
|
-
if (controller.current === attachment) publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) });
|
|
89
|
+
}, error => {
|
|
90
|
+
publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) });
|
|
151
91
|
});
|
|
152
92
|
return () => {
|
|
153
|
-
|
|
93
|
+
detach();
|
|
154
94
|
current.current = initial;
|
|
155
|
-
void attachment.dispose().catch(error => console.error("Could not release audio player", error));
|
|
156
95
|
};
|
|
157
|
-
}, [session, mode, usage, cache, preloadNext, startupBuffer]);
|
|
96
|
+
}, [session, mode, usage, cache, offload, preloadNext, startupBuffer, controller]);
|
|
158
97
|
const commands = useMemo(() => {
|
|
159
98
|
const call = async (operation: string, payload: unknown = {}) => {
|
|
160
99
|
if (!controller.current || !current.current.ready) throw new NativeError("unavailable", "Audio player is not ready");
|
|
@@ -164,7 +103,7 @@ export function usePlayer(options: PlayerOptions = {}) {
|
|
|
164
103
|
async getState(): Promise<PlayerState> {
|
|
165
104
|
const active = controller.current;
|
|
166
105
|
if (!active || !current.current.ready) throw new NativeError("unavailable", "Audio player is not ready");
|
|
167
|
-
return
|
|
106
|
+
return decodePlayerState(JSON.parse(await active.call("getState")));
|
|
168
107
|
},
|
|
169
108
|
seekBy(offset: number) {
|
|
170
109
|
if (!Number.isSafeInteger(offset)) return Promise.reject(new RangeError("Seek offset must be a safe integer in milliseconds"));
|
|
@@ -186,7 +125,7 @@ export function usePlayer(options: PlayerOptions = {}) {
|
|
|
186
125
|
await call("setQueue", { items, startIndex: options.startIndex ?? 0, startPosition, prepare: options.prepare ?? true });
|
|
187
126
|
},
|
|
188
127
|
async add(item: AudioItem, options: { at?: number | "next" } = {}) {
|
|
189
|
-
|
|
128
|
+
validateItem(item);
|
|
190
129
|
if (options.at !== undefined && options.at !== "next" && (!Number.isSafeInteger(options.at) || options.at < 0)) {
|
|
191
130
|
throw new RangeError("Audio insertion index must be a non-negative integer");
|
|
192
131
|
}
|
|
@@ -198,7 +137,7 @@ export function usePlayer(options: PlayerOptions = {}) {
|
|
|
198
137
|
await call("move", { id, index });
|
|
199
138
|
},
|
|
200
139
|
async replace(id: string, item: AudioItem) {
|
|
201
|
-
|
|
140
|
+
validateItem(item);
|
|
202
141
|
await call("replace", { id, item });
|
|
203
142
|
},
|
|
204
143
|
async play(id?: string) {
|
|
@@ -224,6 +163,6 @@ export function usePlayer(options: PlayerOptions = {}) {
|
|
|
224
163
|
return call("seekTo", { value: position });
|
|
225
164
|
},
|
|
226
165
|
};
|
|
227
|
-
}, []);
|
|
166
|
+
}, [controller]);
|
|
228
167
|
return { state, [playbackController]: controller.current?.id, ...commands };
|
|
229
168
|
}
|
package/src/microphone.ts
CHANGED
package/src/recording.ts
CHANGED