ink-framework-audio 0.0.0-stage → 0.1.0-alpha.1
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/ink-native.json +14 -0
- package/package.json +23 -4
- package/src/capture-internal.ts +118 -0
- package/src/index.ts +153 -0
- package/src/microphone.ts +61 -0
- package/src/recording.ts +39 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vandam Dinh
|
|
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/ink-native.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-framework-audio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": "./src/index.ts",
|
|
6
|
+
"./microphone": "./src/microphone.ts",
|
|
7
|
+
"./recording": "./src/recording.ts"
|
|
8
|
+
},
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@ink/files": "npm:ink-framework-files@0.1.0-alpha.1",
|
|
11
|
+
"ink": "npm:ink-framework@0.1.0-alpha.1",
|
|
12
|
+
"react": "19.2.8"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/No-Scrolling/ink.git",
|
|
18
|
+
"directory": "packages/audio"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"*.json",
|
|
23
|
+
"*.ts"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createElement, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { Snapshot, SnapshotSource } from "ink";
|
|
3
|
+
import { NativeError } from "ink/native";
|
|
4
|
+
import { attachNativeController } from "ink/native/controller";
|
|
5
|
+
|
|
6
|
+
const displayController = Symbol("captureController");
|
|
7
|
+
export type CaptureDisplay = { readonly [displayController]: number | null };
|
|
8
|
+
|
|
9
|
+
export function captureReadout(source: { display: CaptureDisplay }, kind: "recorder" | "level" | "pitch") {
|
|
10
|
+
const controller = source.display[displayController];
|
|
11
|
+
return controller === null ? null : createElement("CaptureReadout", { controller, kind });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function fields(value: unknown) {
|
|
15
|
+
if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid audio capture state");
|
|
16
|
+
return {
|
|
17
|
+
text(name: string) {
|
|
18
|
+
const result: unknown = Reflect.get(value, name);
|
|
19
|
+
if (typeof result !== "string") throw new NativeError("protocol", `Invalid audio capture ${name}`);
|
|
20
|
+
return result;
|
|
21
|
+
},
|
|
22
|
+
number(name: string, minimum = 0) {
|
|
23
|
+
const result: unknown = Reflect.get(value, name);
|
|
24
|
+
if (typeof result !== "number" || !Number.isFinite(result) || result < minimum) {
|
|
25
|
+
throw new NativeError("protocol", `Invalid audio capture ${name}`);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
},
|
|
29
|
+
error(): Error | null {
|
|
30
|
+
if (Reflect.get(value, "status") !== "error") return null;
|
|
31
|
+
const detail: unknown = Reflect.get(value, "error");
|
|
32
|
+
if (typeof detail !== "object" || detail === null
|
|
33
|
+
|| !("kind" in detail) || typeof detail.kind !== "string"
|
|
34
|
+
|| !("message" in detail) || typeof detail.message !== "string") {
|
|
35
|
+
throw new NativeError("protocol", "Invalid audio capture error");
|
|
36
|
+
}
|
|
37
|
+
return new NativeError(detail.kind, detail.message, "retryable" in detail && detail.retryable === true);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useCapture<State extends { status: string; error: Error | null }, Status extends { status: string; error: Error | null }>(
|
|
43
|
+
kind: "recorder" | "level" | "pitch",
|
|
44
|
+
initial: State,
|
|
45
|
+
decode: (value: unknown) => State,
|
|
46
|
+
selectStatus: (value: State) => Status,
|
|
47
|
+
referenceHz?: number,
|
|
48
|
+
) {
|
|
49
|
+
const [state, setState] = useState(() => selectStatus(initial));
|
|
50
|
+
const [id, setId] = useState<number | null>(null);
|
|
51
|
+
const controller = useRef<ReturnType<typeof attachNativeController> | null>(null);
|
|
52
|
+
const channel = useMemo(() => {
|
|
53
|
+
let snapshot: Snapshot<State> = { status: "loading" };
|
|
54
|
+
const listeners = new Set<() => void>();
|
|
55
|
+
const publish = (value: Snapshot<State>) => {
|
|
56
|
+
snapshot = value;
|
|
57
|
+
for (const listener of listeners) listener();
|
|
58
|
+
};
|
|
59
|
+
const observe = () => {
|
|
60
|
+
const active = controller.current;
|
|
61
|
+
if (!active) return;
|
|
62
|
+
void active.call("observeMeasurements", { enabled: listeners.size > 0 }).catch(error => {
|
|
63
|
+
if (controller.current === active) publish({ status: "error", error: error instanceof Error ? error : new Error(String(error)) });
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const source: SnapshotSource<State> = {
|
|
67
|
+
getSnapshot: () => snapshot,
|
|
68
|
+
subscribe(listener) {
|
|
69
|
+
listeners.add(listener);
|
|
70
|
+
if (listeners.size === 1) observe();
|
|
71
|
+
return () => {
|
|
72
|
+
listeners.delete(listener);
|
|
73
|
+
if (!listeners.size) observe();
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
return { source, publish, observe };
|
|
78
|
+
}, []);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
setState(selectStatus(initial));
|
|
81
|
+
setId(null);
|
|
82
|
+
channel.publish({ status: "loading" });
|
|
83
|
+
let statusKey = "";
|
|
84
|
+
const publish = (value: State) => {
|
|
85
|
+
const status = selectStatus(value);
|
|
86
|
+
const key = JSON.stringify([status, status.error?.message]);
|
|
87
|
+
if (key !== statusKey) { statusKey = key; setState(status); }
|
|
88
|
+
channel.publish({ status: "ready", data: value });
|
|
89
|
+
};
|
|
90
|
+
const fail = (error: unknown) => {
|
|
91
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
92
|
+
setState({ ...selectStatus(initial), status: "error", error: failure });
|
|
93
|
+
channel.publish({ status: "error", error: failure });
|
|
94
|
+
};
|
|
95
|
+
const attachment = attachNativeController("audio", { kind, config: referenceHz === undefined ? {} : { referenceHz } }, value => {
|
|
96
|
+
if (controller.current !== attachment) return;
|
|
97
|
+
try { publish(decode(value)); }
|
|
98
|
+
catch (error) { fail(error); }
|
|
99
|
+
});
|
|
100
|
+
controller.current = attachment;
|
|
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]);
|
|
112
|
+
const call = useMemo(() => async (operation: string) => {
|
|
113
|
+
if (!controller.current) throw new NativeError("unavailable", "Audio capture is not attached");
|
|
114
|
+
return controller.current.call(operation);
|
|
115
|
+
}, []);
|
|
116
|
+
const display = useMemo(() => ({ [displayController]: id }), [id]);
|
|
117
|
+
return { state, ready: id !== null, call, display, measurements: channel.source };
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { attachNativeController } from "ink/native/controller";
|
|
3
|
+
import { NativeError } from "ink/native";
|
|
4
|
+
import { playbackController } from "ink/internal/playback";
|
|
5
|
+
|
|
6
|
+
export interface AudioItem {
|
|
7
|
+
id: string;
|
|
8
|
+
src: string;
|
|
9
|
+
title: string;
|
|
10
|
+
artist?: string;
|
|
11
|
+
album?: string;
|
|
12
|
+
artwork?: string;
|
|
13
|
+
duration?: number;
|
|
14
|
+
}
|
|
15
|
+
export type SourceReplacement = { prepare?: boolean } & ({ position?: number; offset?: never } | { offset: number; position?: never });
|
|
16
|
+
|
|
17
|
+
export interface PlayerState {
|
|
18
|
+
ready: boolean;
|
|
19
|
+
playing: boolean;
|
|
20
|
+
playWhenReady: boolean;
|
|
21
|
+
buffering: boolean;
|
|
22
|
+
loading: boolean;
|
|
23
|
+
ended: boolean;
|
|
24
|
+
current: AudioItem | null;
|
|
25
|
+
index: number;
|
|
26
|
+
position: number;
|
|
27
|
+
duration: number;
|
|
28
|
+
speed: number;
|
|
29
|
+
skipSilence: boolean;
|
|
30
|
+
voiceBoost: boolean;
|
|
31
|
+
silenceSaved: number;
|
|
32
|
+
error: Error | null;
|
|
33
|
+
}
|
|
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 };
|
|
35
|
+
|
|
36
|
+
function decode(value: unknown): PlayerState {
|
|
37
|
+
if (typeof value !== "object" || value === null) throw new NativeError("protocol", "Invalid player state");
|
|
38
|
+
const field = (name: string): unknown => Reflect.get(value, name);
|
|
39
|
+
const text = (name: string) => {
|
|
40
|
+
const result = field(name);
|
|
41
|
+
if (typeof result !== "string") throw new NativeError("protocol", `Invalid player ${name}`);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
const number = (name: string) => {
|
|
45
|
+
const result = field(name);
|
|
46
|
+
if (typeof result !== "number" || !Number.isFinite(result) || result < 0) throw new NativeError("protocol", `Invalid player ${name}`);
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
const status = text("status");
|
|
50
|
+
if (!["idle", "loading", "playing", "paused", "ended", "error"].includes(status) || typeof field("ready") !== "boolean") {
|
|
51
|
+
throw new NativeError("protocol", "Invalid player status");
|
|
52
|
+
}
|
|
53
|
+
let error: Error | null = null;
|
|
54
|
+
if (status === "error") {
|
|
55
|
+
const detail = field("error");
|
|
56
|
+
if (typeof detail !== "object" || detail === null
|
|
57
|
+
|| !("message" in detail) || typeof detail.message !== "string"
|
|
58
|
+
|| !("kind" in detail) || typeof detail.kind !== "string") throw new NativeError("protocol", "Invalid player error");
|
|
59
|
+
error = new NativeError(detail.kind, detail.message, "retryable" in detail && detail.retryable === true);
|
|
60
|
+
}
|
|
61
|
+
const src = text("src");
|
|
62
|
+
const index = field("index");
|
|
63
|
+
if (typeof index !== "number" || !Number.isSafeInteger(index) || index < -1) {
|
|
64
|
+
throw new NativeError("protocol", "Invalid player queue index");
|
|
65
|
+
}
|
|
66
|
+
const duration = number("durationMs");
|
|
67
|
+
return {
|
|
68
|
+
ready: field("ready") === true,
|
|
69
|
+
playing: status === "playing",
|
|
70
|
+
playWhenReady: field("playWhenReady") === true,
|
|
71
|
+
buffering: status === "loading",
|
|
72
|
+
loading: field("loading") === true,
|
|
73
|
+
ended: status === "ended",
|
|
74
|
+
index,
|
|
75
|
+
current: src ? { id: text("id"), src, title: text("title"), artist: text("artist"), album: text("album"), artwork: text("artwork"), duration } : null,
|
|
76
|
+
position: number("positionMs"), duration, speed: number("speed"),
|
|
77
|
+
skipSilence: field("skipSilence") === true, voiceBoost: field("voiceBoost") === true,
|
|
78
|
+
silenceSaved: number("silenceSavedMs"), error,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function usePlayer(options: { session?: string; mode?: "attached" | "detached"; usage?: "music" | "speech" } = {}) {
|
|
83
|
+
const { session = "main", mode = "attached", usage = "music" } = options;
|
|
84
|
+
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");
|
|
85
|
+
const [state, setState] = useState(initial);
|
|
86
|
+
const current = useRef(initial);
|
|
87
|
+
const controller = useRef<ReturnType<typeof attachNativeController> | null>(null);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const publish = (next: PlayerState) => { current.current = next; setState(next); };
|
|
90
|
+
publish(initial);
|
|
91
|
+
const attachment = attachNativeController("audio", { kind: "player", config: { session, playback: mode, usage } }, value => {
|
|
92
|
+
try { publish(decode(value)); }
|
|
93
|
+
catch (error) { publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) }); }
|
|
94
|
+
});
|
|
95
|
+
controller.current = attachment;
|
|
96
|
+
void attachment.ready.catch(error => {
|
|
97
|
+
if (controller.current === attachment) publish({ ...initial, error: error instanceof Error ? error : new Error(String(error)) });
|
|
98
|
+
});
|
|
99
|
+
return () => {
|
|
100
|
+
controller.current = null;
|
|
101
|
+
current.current = initial;
|
|
102
|
+
void attachment.dispose().catch(error => console.error("Could not release audio player", error));
|
|
103
|
+
};
|
|
104
|
+
}, [session, mode, usage]);
|
|
105
|
+
const commands = useMemo(() => {
|
|
106
|
+
const call = async (operation: string, payload: unknown = {}) => {
|
|
107
|
+
if (!controller.current || !current.current.ready) throw new NativeError("unavailable", "Audio player is not ready");
|
|
108
|
+
await controller.current.call(operation, payload);
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
async getState(): Promise<PlayerState> {
|
|
112
|
+
const active = controller.current;
|
|
113
|
+
if (!active || !current.current.ready) throw new NativeError("unavailable", "Audio player is not ready");
|
|
114
|
+
return decode(JSON.parse(await active.call("getState")));
|
|
115
|
+
},
|
|
116
|
+
seekBy(offset: number) {
|
|
117
|
+
if (!Number.isSafeInteger(offset)) return Promise.reject(new RangeError("Seek offset must be a safe integer in milliseconds"));
|
|
118
|
+
return call("seekBy", { value: offset });
|
|
119
|
+
},
|
|
120
|
+
replaceSource(id: string, src: string, options: SourceReplacement = {}) {
|
|
121
|
+
if (options.position !== undefined && (!Number.isFinite(options.position) || options.position < 0)) {
|
|
122
|
+
return Promise.reject(new RangeError("Audio position must be non-negative milliseconds"));
|
|
123
|
+
}
|
|
124
|
+
if (options.offset !== undefined && !Number.isSafeInteger(options.offset)) {
|
|
125
|
+
return Promise.reject(new RangeError("Seek offset must be a safe integer in milliseconds"));
|
|
126
|
+
}
|
|
127
|
+
return call("replaceSource", { id, src, ...options });
|
|
128
|
+
},
|
|
129
|
+
setQueue(items: readonly AudioItem[], options: { startIndex?: number; startPosition?: number; prepare?: boolean } = {}) {
|
|
130
|
+
const startPosition = options.startPosition ?? 0;
|
|
131
|
+
if (!Number.isFinite(startPosition) || startPosition < 0) return Promise.reject(new RangeError("Audio position must be a non-negative number of milliseconds"));
|
|
132
|
+
return call("setQueue", { items, startIndex: options.startIndex ?? 0, startPosition, prepare: options.prepare ?? true });
|
|
133
|
+
},
|
|
134
|
+
play: () => call("play"),
|
|
135
|
+
pause: () => call("pause"),
|
|
136
|
+
toggle: () => call("toggle"),
|
|
137
|
+
stop: () => call("stop"),
|
|
138
|
+
setSpeed(speed: number) {
|
|
139
|
+
if (!Number.isFinite(speed) || speed < 0.25 || speed > 4) return Promise.reject(new RangeError("Playback speed must be between 0.25 and 4"));
|
|
140
|
+
return call("setSpeed", { value: speed });
|
|
141
|
+
},
|
|
142
|
+
setVoiceBoost: (enabled: boolean) => call("setVoiceBoost", { value: enabled }),
|
|
143
|
+
setSkipSilence: (enabled: boolean) => call("setSkipSilence", { value: enabled }),
|
|
144
|
+
next: () => call("next"),
|
|
145
|
+
previous: () => call("previous"),
|
|
146
|
+
seek(position: number) {
|
|
147
|
+
if (!Number.isFinite(position) || position < 0) return Promise.reject(new RangeError("Audio position must be a non-negative number of milliseconds"));
|
|
148
|
+
return call("seekTo", { value: position });
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}, []);
|
|
152
|
+
return { state, [playbackController]: controller.current?.id, ...commands };
|
|
153
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createElement, useMemo } from "react";
|
|
2
|
+
import { NativeError } from "ink/native";
|
|
3
|
+
import { captureReadout, fields, useCapture, type CaptureDisplay } from "./capture-internal";
|
|
4
|
+
|
|
5
|
+
export interface LevelState {
|
|
6
|
+
status: "idle" | "listening" | "active" | "clipping" | "error";
|
|
7
|
+
rms: number;
|
|
8
|
+
peak: number;
|
|
9
|
+
error: Error | null;
|
|
10
|
+
}
|
|
11
|
+
const levelInitial: LevelState = { status: "idle", rms: 0, peak: 0, error: null };
|
|
12
|
+
function decodeLevel(value: unknown): LevelState {
|
|
13
|
+
const field = fields(value);
|
|
14
|
+
const status = field.text("status");
|
|
15
|
+
if (status !== "idle" && status !== "listening" && status !== "active" && status !== "clipping" && status !== "error") {
|
|
16
|
+
throw new NativeError("protocol", "Invalid level meter status");
|
|
17
|
+
}
|
|
18
|
+
return { status, rms: field.number("rms"), peak: field.number("peak"), error: field.error() };
|
|
19
|
+
}
|
|
20
|
+
const levelStatus = ({ status, error }: LevelState) => ({ status, error });
|
|
21
|
+
export function useLevelMeter() {
|
|
22
|
+
const { state, ready, call, display, measurements } = useCapture("level", levelInitial, decodeLevel, levelStatus);
|
|
23
|
+
const commands = useMemo(() => ({ start: async () => { await call("start"); }, stop: async () => { await call("stop"); } }), [call]);
|
|
24
|
+
return { state, ready, display, measurements, ...commands };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PitchState {
|
|
28
|
+
status: "idle" | "listening" | "active" | "error";
|
|
29
|
+
frequency: number;
|
|
30
|
+
note: string;
|
|
31
|
+
octave: number;
|
|
32
|
+
cents: number;
|
|
33
|
+
confidence: number;
|
|
34
|
+
error: Error | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pitchInitial: PitchState = { status: "idle", frequency: 0, note: "", octave: 0, cents: 0, confidence: 0, error: null };
|
|
38
|
+
function decodePitch(value: unknown): PitchState {
|
|
39
|
+
const field = fields(value);
|
|
40
|
+
const status = field.text("status");
|
|
41
|
+
if (status !== "idle" && status !== "listening" && status !== "active" && status !== "error") {
|
|
42
|
+
throw new NativeError("protocol", "Invalid pitch detector status");
|
|
43
|
+
}
|
|
44
|
+
return { status, frequency: field.number("frequencyHz"), note: field.text("note"),
|
|
45
|
+
octave: field.number("octave", -Infinity), cents: field.number("cents", -Infinity),
|
|
46
|
+
confidence: field.number("confidence"), error: field.error() };
|
|
47
|
+
}
|
|
48
|
+
const pitchStatus = ({ status, error }: PitchState) => ({ status, error });
|
|
49
|
+
export function usePitchDetector({ referenceHz = 440 }: { referenceHz?: number } = {}) {
|
|
50
|
+
if (!Number.isFinite(referenceHz) || referenceHz <= 0) throw new RangeError("Pitch reference must be a positive frequency");
|
|
51
|
+
const { state, ready, call, display, measurements } = useCapture("pitch", pitchInitial, decodePitch, pitchStatus, referenceHz);
|
|
52
|
+
const commands = useMemo(() => ({ start: async () => { await call("start"); }, stop: async () => { await call("stop"); } }), [call]);
|
|
53
|
+
return { state, ready, display, measurements, ...commands };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function LevelReadout({ meter }: { meter: { display: CaptureDisplay } }) { return captureReadout(meter, "level"); }
|
|
57
|
+
export function PitchReadout({ detector }: { detector: { display: CaptureDisplay } }) { return captureReadout(detector, "pitch"); }
|
|
58
|
+
|
|
59
|
+
export function PitchIndicator({ cents }: { cents: number | null }) {
|
|
60
|
+
return createElement("PitchIndicator", { cents });
|
|
61
|
+
}
|
package/src/recording.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { NativeError } from "ink/native";
|
|
3
|
+
import type { FileRef } from "@ink/files";
|
|
4
|
+
import { captureReadout, fields, useCapture, type CaptureDisplay } from "./capture-internal";
|
|
5
|
+
|
|
6
|
+
export interface RecorderState {
|
|
7
|
+
status: "idle" | "recording" | "stopping" | "ready" | "error";
|
|
8
|
+
duration: number;
|
|
9
|
+
recording: (FileRef & { duration: number }) | null;
|
|
10
|
+
error: Error | null;
|
|
11
|
+
}
|
|
12
|
+
const recorderInitial: RecorderState = { status: "idle", duration: 0, recording: null, error: null };
|
|
13
|
+
function decodeRecorder(value: unknown): RecorderState {
|
|
14
|
+
const field = fields(value);
|
|
15
|
+
const status = field.text("status");
|
|
16
|
+
if (status !== "idle" && status !== "recording" && status !== "stopping" && status !== "ready" && status !== "error") {
|
|
17
|
+
throw new NativeError("protocol", "Invalid recorder status");
|
|
18
|
+
}
|
|
19
|
+
const id = field.text("id");
|
|
20
|
+
return { status, duration: field.number("durationMs"), error: field.error(),
|
|
21
|
+
recording: id ? { id, src: `ink-file://${id}`, name: field.text("name"), mimeType: field.text("mimeType"), size: field.number("size"), duration: field.number("recordingDurationMs") } : null };
|
|
22
|
+
}
|
|
23
|
+
const recorderStatus = ({ status, error, recording }: RecorderState) => ({ status, error, recording });
|
|
24
|
+
export function useRecorder() {
|
|
25
|
+
const { state, ready, call, display, measurements } = useCapture("recorder", recorderInitial, decodeRecorder, recorderStatus);
|
|
26
|
+
const commands = useMemo(() => ({
|
|
27
|
+
start: async () => { await call("start"); },
|
|
28
|
+
async stop(): Promise<FileRef & { duration: number }> {
|
|
29
|
+
const completed = decodeRecorder(JSON.parse(await call("stop")));
|
|
30
|
+
if (completed.status !== "ready" || !completed.recording) throw new NativeError("protocol", "Recording did not return a saved file");
|
|
31
|
+
return completed.recording;
|
|
32
|
+
},
|
|
33
|
+
cancel: async () => { await call("cancel"); },
|
|
34
|
+
delete: async () => { await call("delete"); },
|
|
35
|
+
}), [call]);
|
|
36
|
+
return { state, ready, display, measurements, ...commands };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function RecordingDuration({ recorder }: { recorder: { display: CaptureDisplay } }) { return captureReadout(recorder, "recorder"); }
|
package/README.md
DELETED