@hachej/boring-feedback 0.4.4 → 0.4.5

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
@@ -7,7 +7,7 @@ Import the capability you need. There is no root export.
7
7
 
8
8
  - `@hachej/boring-feedback/format` defines report data.
9
9
  - `@hachej/boring-feedback/store` reads and conditionally publishes reports.
10
- - `@hachej/boring-feedback/page` captures page observations.
10
+ - `@hachej/boring-feedback/page` captures page observations. Its microphone API remains available and re-exports the [shared UI capture implementation](../ui/README.md#optional-microphone-capture).
11
11
  - `@hachej/boring-feedback/ui` supplies React controls.
12
12
  - `@hachej/boring-feedback/agent` supplies the optional native Pi adapter.
13
13
  - `@hachej/boring-feedback/preview` and `@hachej/boring-feedback/tickets` supply integrations.
@@ -1,43 +1,2 @@
1
- /** Why the microphone could not be used, for a short human message: `denied` (the person or the browser refused the permission),
2
- * `no-microphone`, `busy` (in use or could not be opened), `insecure` (http on a name other than localhost: no media devices at all),
3
- * `unsupported` (no MediaRecorder, or it cannot record this microphone), `failed` (anything else). */
4
- export type VoiceRefusal = 'denied' | 'no-microphone' | 'busy' | 'insecure' | 'unsupported' | 'failed';
5
- export type VoiceStart = {
6
- readonly kind: 'recording';
7
- } | {
8
- readonly kind: 'refused';
9
- readonly reason: string;
10
- readonly code?: VoiceRefusal;
11
- };
12
- export interface VoiceRecording {
13
- readonly audio: Blob;
14
- readonly mimeType: string;
15
- /** Page time (`performance.now()` ms) of the first recorded sample. */
16
- readonly startedAt: number;
17
- readonly endedAt: number;
18
- /** The recording reached `maxMs` and stopped on its own. */
19
- readonly truncated: boolean;
20
- }
21
- export interface VoiceCapture {
22
- readonly start: () => Promise<VoiceStart>;
23
- /** Ends the recording and returns it; null when nothing was recorded (never started, cancelled, or empty). */
24
- readonly stop: () => Promise<VoiceRecording | null>;
25
- /** Ends the recording, releases the microphone and discards the audio. */
26
- readonly cancel: () => void;
27
- }
28
- export interface VoiceCaptureOptions {
29
- readonly getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
30
- /** Longest recording in milliseconds (default 5 minutes); the recording stops there and `stop()` reports `truncated`. */
31
- readonly maxMs?: number;
32
- /** The page clock (default `performance.now()`). */
33
- readonly now?: () => number;
34
- }
35
- export declare const DEFAULT_MAX_VOICE_MS: number;
36
- /** Container types tried in order; the first the browser records wins. */
37
- export declare const VOICE_MIME_TYPES: readonly ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
38
- /** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
39
- export declare function browserGetUserMedia(): ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | null;
40
- /** Short human messages, one per refusal; each says what to do next. */
41
- export declare const VOICE_REFUSALS: Readonly<Record<VoiceRefusal, string>>;
42
- export declare function createVoiceCapture(options?: VoiceCaptureOptions): VoiceCapture;
1
+ export * from '@hachej/boring-ui-kit/voice-capture';
43
2
  //# sourceMappingURL=capture.d.ts.map
@@ -1,186 +1,2 @@
1
- // Voice capture for a feedback session: the person's microphone recorded while they point, with the page-clock time of the first
2
- // recorded sample so speech can be aligned with the pointer (./align.ts).
3
- //
4
- // Microphone access (getUserMedia) exists only in secure contexts (https or localhost) and needs the person's permission. The
5
- // capture never opens it on load: only `start()`, called from the person's click, does, through an injectable `getUserMedia`
6
- // whose default feature-detects `navigator.mediaDevices.getUserMedia` and refuses with a reason when it is missing. A host that
7
- // owns microphone policy passes its own. `npm run check` (scripts/check-platform-apis.mjs) allowlists this file on that condition.
8
- //
9
- // The clock. MediaRecorder hands audio over late and in chunks, so neither its `start` event nor a chunk's arrival says when the
10
- // first recorded sample was heard. `npm run feedback:voice-sync` (tools/feedback-voice-sync.mjs) measured seven candidate
11
- // strategies in Chromium against an independent reader of the same microphone; the page time read immediately before
12
- // `MediaRecorder.start()` was the most accurate (2–4 ms mean, under 9 ms max over three batches, with the fake device's dropped
13
- // time removed), while the `start` event is about 60 ms late (up to 125 ms) and stop-minus-duration 30–40 ms late. So
14
- // `startedAt` is that time.
15
- // `endedAt` is `startedAt` plus the decoded duration when the page can decode the recording, else the `stop` event's time.
16
- // Measured in Chromium only; a microphone that warms up after getUserMedia resolves (no audio yet when recording starts) would
17
- // make `startedAt` early by the warm-up.
18
- export const DEFAULT_MAX_VOICE_MS = 5 * 60_000;
19
- /** Container types tried in order; the first the browser records wins. */
20
- export const VOICE_MIME_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
21
- /** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
22
- export function browserGetUserMedia() {
23
- if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function')
24
- return null;
25
- const devices = globalThis.navigator.mediaDevices;
26
- return constraints => devices.getUserMedia(constraints);
27
- }
28
- /** Short human messages, one per refusal; each says what to do next. */
29
- export const VOICE_REFUSALS = {
30
- denied: 'Microphone blocked. Allow it for this site in the browser, then press Record again.',
31
- 'no-microphone': 'No microphone found. Connect one, then press Record again.',
32
- busy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',
33
- insecure: 'Recording needs a secure page (https or localhost).',
34
- unsupported: 'This browser cannot record audio here.',
35
- failed: 'The microphone could not be started.',
36
- };
37
- const refused = (code, detail) => ({ kind: 'refused', code, reason: detail ? `${VOICE_REFUSALS[code]} (${detail})` : VOICE_REFUSALS[code] });
38
- function refusalOf(error) {
39
- const name = error?.name;
40
- if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || name === 'SecurityError')
41
- return refused('denied');
42
- if (name === 'NotFoundError' || name === 'DevicesNotFoundError' || name === 'OverconstrainedError')
43
- return refused('no-microphone');
44
- if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError')
45
- return refused('busy');
46
- return refused('failed', error instanceof Error ? error.message : String(error));
47
- }
48
- /** Media devices missing on a plain-http page: the usual reason is an insecure origin (http on a name that is not local; a local
49
- * http page has them). Wording only; the capability itself is feature-detected (`browserGetUserMedia`). */
50
- const plainHttpPage = () => globalThis.location?.protocol === 'http:';
51
- const release = (stream) => { for (const track of stream?.getTracks() ?? [])
52
- track.stop(); };
53
- /** Duration in milliseconds of recorded audio, when the page can decode it (OfflineAudioContext or AudioContext). */
54
- async function decodedMs(audio) {
55
- try {
56
- const Offline = globalThis.OfflineAudioContext;
57
- if (typeof Offline !== 'function' || audio.size === 0)
58
- return null;
59
- const context = new Offline(1, 1, 48_000);
60
- const buffer = await context.decodeAudioData(await audio.arrayBuffer());
61
- return Number.isFinite(buffer.duration) ? buffer.duration * 1000 : null;
62
- }
63
- catch {
64
- return null;
65
- }
66
- }
67
- export function createVoiceCapture(options = {}) {
68
- const now = options.now ?? (() => performance.now());
69
- const maxMs = options.maxMs ?? DEFAULT_MAX_VOICE_MS;
70
- let session = null;
71
- let opening = false;
72
- let cancelledWhileOpening = false;
73
- const finish = (current, truncated) => {
74
- if (current.timer !== null) {
75
- clearTimeout(current.timer);
76
- current.timer = null;
77
- }
78
- if (truncated)
79
- current.truncated = true;
80
- if (current.recorder.state !== 'inactive') {
81
- try {
82
- current.recorder.stop();
83
- }
84
- catch { /* already stopping */ }
85
- }
86
- };
87
- return {
88
- async start() {
89
- if (session || opening)
90
- return { kind: 'refused', code: 'failed', reason: 'Already recording.' };
91
- const Recorder = globalThis.MediaRecorder;
92
- const open = options.getUserMedia ?? browserGetUserMedia();
93
- // No media devices on an insecure origin: say that, not "no microphone" (the person can fix the address, not the hardware).
94
- if (!open)
95
- return refused(plainHttpPage() ? 'insecure' : 'unsupported');
96
- if (typeof Recorder !== 'function')
97
- return refused('unsupported', 'no MediaRecorder');
98
- // getUserMedia is called synchronously from here, inside the person's click (no await before it), so browsers that tie the
99
- // permission prompt to a user gesture show it.
100
- opening = true;
101
- cancelledWhileOpening = false;
102
- let stream;
103
- try {
104
- stream = await open({ audio: true });
105
- }
106
- catch (error) {
107
- opening = false;
108
- return refusalOf(error);
109
- }
110
- if (cancelledWhileOpening) {
111
- opening = false;
112
- release(stream);
113
- return { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' };
114
- }
115
- const supported = (type) => { try {
116
- return typeof Recorder.isTypeSupported !== 'function' || Recorder.isTypeSupported(type);
117
- }
118
- catch {
119
- return false;
120
- } };
121
- const preferred = VOICE_MIME_TYPES.find(supported);
122
- let recorder;
123
- try {
124
- recorder = preferred ? new Recorder(stream, { mimeType: preferred }) : new Recorder(stream);
125
- }
126
- catch (error) {
127
- opening = false;
128
- release(stream);
129
- return refused('unsupported', error instanceof Error ? error.message : String(error));
130
- }
131
- let settle;
132
- const current = {
133
- stream, recorder, mimeType: preferred ?? '', chunks: [], startedAt: null, stoppedAt: null, truncated: false, timer: null,
134
- stopped: new Promise(resolve => { settle = resolve; }), cancelled: false,
135
- };
136
- recorder.addEventListener('dataavailable', event => { if (event.data && event.data.size > 0)
137
- current.chunks.push(event.data); });
138
- recorder.addEventListener('stop', () => { current.stoppedAt = now(); release(stream); settle(); });
139
- recorder.addEventListener('error', () => { current.stoppedAt ??= now(); release(stream); settle(); });
140
- try {
141
- current.startedAt = now();
142
- recorder.start(1000);
143
- }
144
- catch (error) {
145
- opening = false;
146
- release(stream);
147
- return refused('failed', error instanceof Error ? error.message : String(error));
148
- }
149
- if (Number.isFinite(maxMs) && maxMs > 0)
150
- current.timer = setTimeout(() => finish(current, true), maxMs);
151
- session = current;
152
- opening = false;
153
- return { kind: 'recording' };
154
- },
155
- async stop() {
156
- const current = session;
157
- if (!current)
158
- return null;
159
- finish(current, false);
160
- await current.stopped;
161
- session = null;
162
- if (current.cancelled)
163
- return null;
164
- const mimeType = current.recorder.mimeType || current.mimeType || current.chunks[0]?.type || 'audio/webm';
165
- const audio = new Blob(current.chunks, { type: mimeType });
166
- if (audio.size === 0)
167
- return null;
168
- const startedAt = current.startedAt ?? current.stoppedAt ?? now();
169
- const duration = await decodedMs(audio);
170
- const endedAt = duration === null ? Math.max(startedAt, current.stoppedAt ?? now()) : startedAt + duration;
171
- return { audio, mimeType, startedAt, endedAt, truncated: current.truncated };
172
- },
173
- cancel() {
174
- const current = session;
175
- if (opening)
176
- cancelledWhileOpening = true;
177
- if (!current)
178
- return;
179
- session = null;
180
- current.cancelled = true;
181
- current.chunks.length = 0;
182
- finish(current, false);
183
- release(current.stream);
184
- },
185
- };
186
- }
1
+ // Compatibility: microphone capture is shared with chat, independently of the feedback plug-in.
2
+ export * from '@hachej/boring-ui-kit/voice-capture';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-feedback",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -52,9 +52,9 @@
52
52
  }
53
53
  },
54
54
  "peerDependencies": {
55
- "@hachej/boring-ui-kit": "^0.4.4",
56
- "@hachej/boring-files": "^0.4.4",
57
- "@hachej/boring-agent": "^0.4.4",
55
+ "@hachej/boring-ui-kit": "^0.4.5",
56
+ "@hachej/boring-files": "^0.4.5",
57
+ "@hachej/boring-agent": "^0.4.5",
58
58
  "@earendil-works/pi-durable": "~1.2.0",
59
59
  "@earendil-works/pi-ai": "~1.2.0",
60
60
  "@earendil-works/chord": "~1.2.0",