pi-push-to-talk 0.1.0 → 0.2.0
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 +2 -3
- package/index.ts +10 -3
- package/package.json +9 -1
- package/src/config.ts +2 -2
- package/src/editor.ts +6 -5
- package/src/hold.ts +47 -19
- package/src/session.ts +96 -27
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Remove any other voice extension first. Several of them also bind space or repla
|
|
|
25
25
|
| Pause 2.5 s while holding | The open phrase is committed and dictation continues |
|
|
26
26
|
| Tap space | An ordinary space, as usual |
|
|
27
27
|
|
|
28
|
-
While you speak, the open phrase is drawn dim; committed text is normal.
|
|
28
|
+
While you speak, the open phrase is drawn dim; committed text is normal. The footer alternates a red `●` with `○` every 600 ms: `● listening` while the microphone is live, or `● loading model` until Whisper is ready.
|
|
29
29
|
|
|
30
30
|
## First run
|
|
31
31
|
|
|
@@ -50,7 +50,6 @@ Extraction uses the system `tar` with bzip2 support, which is present on macOS,
|
|
|
50
50
|
|
|
51
51
|
- Undo history records each preview rewrite; `Ctrl+Z` after a dictation steps back through them. The editor API offers no way to write without an undo entry.
|
|
52
52
|
- English only.
|
|
53
|
-
- A true sentence end followed by a pause longer than 2.5 s loses its full stop, because a mid-session commit drops Whisper's trailing period.
|
|
54
53
|
- Brackets are stripped from transcripts, since Whisper emits them as sound captions. Dictating code with brackets is not supported.
|
|
55
54
|
- The cursor is restored with simulated left-arrow presses, capped at 4,000 characters. Inserting near the start of a very long prompt can leave the cursor short of the dictation point.
|
|
56
55
|
- Nested editors (dialogs) do not accept dictation, only normal typing.
|
|
@@ -61,7 +60,7 @@ Both are off unless the environment variable is set; both write only to `/tmp`.
|
|
|
61
60
|
|
|
62
61
|
| Variable | File | Content |
|
|
63
62
|
|---|---|---|
|
|
64
|
-
| `PI_DICTATION_DEBUG=1` | `/tmp/pi-dictation.log` | Space events, phrase decodes,
|
|
63
|
+
| `PI_DICTATION_DEBUG=1` | `/tmp/pi-dictation.log` | Space events, phrase decodes, cancels, and session commits |
|
|
65
64
|
| `PI_DICTATION_PROBE=1` | `/tmp/pi-dictation-probe.log` | Every terminal key sequence, for diagnosing key encoding. Dictation is disabled in this mode. |
|
|
66
65
|
|
|
67
66
|
## Uninstall
|
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Stt } from "./src/stt.ts";
|
|
|
7
7
|
type Ui = {
|
|
8
8
|
notify: (message: string, level: "info" | "warning" | "error") => void;
|
|
9
9
|
setStatus: (key: string, text: string | undefined) => void;
|
|
10
|
+
theme: { fg: (color: "error", text: string) => string };
|
|
10
11
|
onTerminalInput: (handler: (data: string) => { consume?: boolean } | undefined) => () => void;
|
|
11
12
|
};
|
|
12
13
|
|
|
@@ -18,15 +19,17 @@ export default function dictationExtension(pi: ExtensionAPI): void {
|
|
|
18
19
|
|
|
19
20
|
const getStt = (): Promise<Stt> => {
|
|
20
21
|
if (!sttPromise) {
|
|
21
|
-
|
|
22
|
+
// Model progress has its own key: the listening indicator blinks on the
|
|
23
|
+
// dictation key and must not overwrite a first-run download.
|
|
24
|
+
sttPromise = Stt.create(undefined, (message) => ui?.setStatus("dictation-model", message)).then(
|
|
22
25
|
(stt) => {
|
|
23
|
-
ui?.setStatus("dictation", undefined);
|
|
26
|
+
ui?.setStatus("dictation-model", undefined);
|
|
24
27
|
return stt;
|
|
25
28
|
},
|
|
26
29
|
(error: unknown) => {
|
|
27
30
|
// Do not cache a failure: the next hold retries.
|
|
28
31
|
sttPromise = undefined;
|
|
29
|
-
ui?.setStatus("dictation", undefined);
|
|
32
|
+
ui?.setStatus("dictation-model", undefined);
|
|
30
33
|
throw error;
|
|
31
34
|
},
|
|
32
35
|
);
|
|
@@ -39,6 +42,10 @@ export default function dictationExtension(pi: ExtensionAPI): void {
|
|
|
39
42
|
getStt,
|
|
40
43
|
notify: (message, level) => ui?.notify(message, level),
|
|
41
44
|
status: (text) => ui?.setStatus("dictation", text),
|
|
45
|
+
indicator: (state, filled) => {
|
|
46
|
+
const circle = filled ? (ui?.theme.fg("error", "●") ?? "●") : "○";
|
|
47
|
+
return `${circle} ${state === "loading" ? "loading model" : "listening"}`;
|
|
48
|
+
},
|
|
42
49
|
});
|
|
43
50
|
|
|
44
51
|
pi.on("session_start", (_event, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-push-to-talk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hold space to dictate into pi's prompt, transcribed locally with Whisper.",
|
|
6
6
|
"keywords": [
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
],
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"author": "sqwxl",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/sqwxl/pi-push-to-talk.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/sqwxl/pi-push-to-talk#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/sqwxl/pi-push-to-talk/issues"
|
|
24
|
+
},
|
|
17
25
|
"engines": {
|
|
18
26
|
"node": ">=20"
|
|
19
27
|
},
|
package/src/config.ts
CHANGED
|
@@ -33,8 +33,8 @@ export const MIN_PHRASE_RMS = 0.005;
|
|
|
33
33
|
/** SPACE held this long starts dictating. */
|
|
34
34
|
export const HOLD_MS = 350;
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
export const
|
|
36
|
+
/** The listening indicator alternates filled and empty at this interval. */
|
|
37
|
+
export const LISTENING_BLINK_MS = 600;
|
|
38
38
|
|
|
39
39
|
export const MODEL_DIR = path.join(os.homedir(), ".pi", "models", "whisper-base");
|
|
40
40
|
|
package/src/editor.ts
CHANGED
|
@@ -14,8 +14,6 @@ const MAX_CURSOR_REWIND = 4000;
|
|
|
14
14
|
export class DictationEditor extends CustomEditor {
|
|
15
15
|
/** Characters before the cursor that are not committed yet. */
|
|
16
16
|
private partialLength = 0;
|
|
17
|
-
/** Last buffer state written, so identical rewrites are skipped. */
|
|
18
|
-
private lastWrite = "";
|
|
19
17
|
|
|
20
18
|
override render(width: number): string[] {
|
|
21
19
|
const lines = super.render(width);
|
|
@@ -33,9 +31,12 @@ export class DictationEditor extends CustomEditor {
|
|
|
33
31
|
|
|
34
32
|
/** Replace the buffer, keep the cursor at `cursorOffset`, dim `partialLength` before it. */
|
|
35
33
|
writeAtCursor(text: string, cursorOffset: number, partialLength: number): void {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// Compare against the live editor, not a remembered state: the buffer can
|
|
35
|
+
// change outside this extension (submit, undo, history). A stale cache
|
|
36
|
+
// would skip an identical write and the transcript would never appear.
|
|
37
|
+
if (this.getText() === text && this.insertionOffset() === cursorOffset && this.partialLength === partialLength) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
39
40
|
|
|
40
41
|
this.setText(text);
|
|
41
42
|
let rewind = Math.min(MAX_CURSOR_REWIND, Math.max(0, text.length - cursorOffset));
|
package/src/hold.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* space the editor just typed is taken back and dictation starts.
|
|
8
8
|
*/
|
|
9
9
|
import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
|
|
10
|
-
import { HOLD_MS
|
|
10
|
+
import { HOLD_MS } from "./config.ts";
|
|
11
11
|
import { trace } from "./debug.ts";
|
|
12
12
|
import type { DictationEditor } from "./editor.ts";
|
|
13
13
|
import { createRegion } from "./region.ts";
|
|
@@ -19,8 +19,10 @@ export type HoldDeps = {
|
|
|
19
19
|
getStt: () => Promise<Stt>;
|
|
20
20
|
notify: (message: string, level: "info" | "warning" | "error") => void;
|
|
21
21
|
status: (text: string | undefined) => void;
|
|
22
|
+
/** Indicator text for a blink phase, already coloured. */
|
|
23
|
+
indicator: (state: "loading" | "listening", filled: boolean) => string;
|
|
22
24
|
/** Test seam; production uses DictationSession. */
|
|
23
|
-
createSession?: (stt: Stt
|
|
25
|
+
createSession?: (stt: Stt | Promise<Stt>, ui: SessionUi) => DictationSession;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/** A hold with no release event for this long is treated as stale. */
|
|
@@ -32,8 +34,10 @@ export class SpaceHold {
|
|
|
32
34
|
private holdTimer: ReturnType<typeof setTimeout> | undefined;
|
|
33
35
|
private holding = false;
|
|
34
36
|
private holdingSince = 0;
|
|
35
|
-
private lastTypingAt = 0;
|
|
36
37
|
private starting = false;
|
|
38
|
+
private beginQueued = false;
|
|
39
|
+
/** Bumped by each new press and by cancel; an older begin must not claim it. */
|
|
40
|
+
private holdId = 0;
|
|
37
41
|
/** Resolves when the previous session finished its final decode. */
|
|
38
42
|
private stopping: Promise<unknown> = Promise.resolve();
|
|
39
43
|
|
|
@@ -54,16 +58,15 @@ export class SpaceHold {
|
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
if (matchesKey(data, "space")) return this.handleSpace(isKeyRelease(data), isKeyRepeat(data));
|
|
57
|
-
|
|
58
|
-
if (!isKeyRelease(data) && !isKeyRepeat(data) && data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
59
|
-
this.lastTypingAt = Date.now();
|
|
60
|
-
}
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Abandon any open session without committing, e.g. when the prompt is submitted. */
|
|
65
65
|
cancel(): void {
|
|
66
|
+
trace("cancel");
|
|
66
67
|
this.holding = false;
|
|
68
|
+
this.holdId++;
|
|
69
|
+
this.beginQueued = false;
|
|
67
70
|
this.clearHoldTimer();
|
|
68
71
|
this.session?.cancel();
|
|
69
72
|
this.session = undefined;
|
|
@@ -82,6 +85,9 @@ export class SpaceHold {
|
|
|
82
85
|
if (released) {
|
|
83
86
|
this.holding = false;
|
|
84
87
|
this.clearHoldTimer();
|
|
88
|
+
// A session that failed by itself is already stopping; keep that
|
|
89
|
+
// shutdown visible to the next begin before the key is pressed again.
|
|
90
|
+
this.finish();
|
|
85
91
|
return false; // the space the editor typed stays
|
|
86
92
|
}
|
|
87
93
|
|
|
@@ -96,13 +102,14 @@ export class SpaceHold {
|
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
if (repeated) return false;
|
|
99
|
-
if (Date.now() - this.lastTypingAt < TYPING_COOLDOWN_MS) return false;
|
|
100
105
|
|
|
101
106
|
this.holding = true;
|
|
102
107
|
this.holdingSince = Date.now();
|
|
108
|
+
this.holdId++;
|
|
109
|
+
const id = this.holdId;
|
|
103
110
|
this.holdTimer = setTimeout(() => {
|
|
104
111
|
this.holdTimer = undefined;
|
|
105
|
-
|
|
112
|
+
this.requestBegin(id);
|
|
106
113
|
}, HOLD_MS);
|
|
107
114
|
return false;
|
|
108
115
|
}
|
|
@@ -110,12 +117,27 @@ export class SpaceHold {
|
|
|
110
117
|
private finish(): void {
|
|
111
118
|
this.holding = false;
|
|
112
119
|
this.clearHoldTimer();
|
|
113
|
-
if (this.session)
|
|
114
|
-
|
|
120
|
+
if (!this.session) return;
|
|
121
|
+
this.stopping = this.session.stop().catch((error: unknown) => {
|
|
122
|
+
this.deps.notify(`Dictation: ${String(error)}`, "error");
|
|
123
|
+
});
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
/** Start against the given press; queue it when an older begin is still unwinding. */
|
|
127
|
+
private requestBegin(id: number): void {
|
|
128
|
+
if (id !== this.holdId) return;
|
|
129
|
+
if (this.starting) {
|
|
130
|
+
this.beginQueued = true;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
void this.begin(id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async begin(id: number): Promise<void> {
|
|
137
|
+
if (this.starting) {
|
|
138
|
+
this.beginQueued = true;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
119
141
|
this.starting = true;
|
|
120
142
|
try {
|
|
121
143
|
const editor = this.deps.getEditor();
|
|
@@ -127,22 +149,21 @@ export class SpaceHold {
|
|
|
127
149
|
// A previous stop may still be decoding its tail; its write would land
|
|
128
150
|
// on top of the text this session is about to produce.
|
|
129
151
|
await this.stopping;
|
|
152
|
+
// A newer press now owns the buffer and will start its own session.
|
|
153
|
+
if (id !== this.holdId) return;
|
|
130
154
|
|
|
131
155
|
// Released before capture began: the tap must not vanish.
|
|
132
156
|
if (!this.holding) {
|
|
157
|
+
trace("begin: released while the previous stop finished, space restored");
|
|
133
158
|
editor.typeSpace();
|
|
134
159
|
return;
|
|
135
160
|
}
|
|
136
161
|
|
|
137
|
-
const stt =
|
|
138
|
-
if (!this.holding) {
|
|
139
|
-
editor.typeSpace();
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
162
|
+
const stt = this.deps.getStt();
|
|
143
163
|
const ui: SessionUi = {
|
|
144
164
|
write: (text, cursorOffset, partialLength) => editor.writeAtCursor(text, cursorOffset, partialLength),
|
|
145
165
|
status: (text) => this.deps.status(text),
|
|
166
|
+
indicator: (state, filled) => this.deps.indicator(state, filled),
|
|
146
167
|
notify: (message, level) => this.deps.notify(message, level),
|
|
147
168
|
};
|
|
148
169
|
const create = this.deps.createSession ?? ((engine, sessionUi) => new DictationSession(engine, sessionUi));
|
|
@@ -150,6 +171,9 @@ export class SpaceHold {
|
|
|
150
171
|
this.session = session;
|
|
151
172
|
await session.start(createRegion(editor.getText(), editor.insertionOffset()));
|
|
152
173
|
|
|
174
|
+
// Cancelled while the device was opening: leave the buffer alone.
|
|
175
|
+
if (id !== this.holdId) return;
|
|
176
|
+
|
|
153
177
|
// Released while the capture device was opening: close it, and give the
|
|
154
178
|
// space back if nothing was transcribed.
|
|
155
179
|
if (!this.holding) {
|
|
@@ -162,6 +186,10 @@ export class SpaceHold {
|
|
|
162
186
|
if (!this.session?.isActive()) this.deps.getEditor()?.typeSpace();
|
|
163
187
|
} finally {
|
|
164
188
|
this.starting = false;
|
|
189
|
+
if (this.beginQueued) {
|
|
190
|
+
this.beginQueued = false;
|
|
191
|
+
if (this.holding && !this.session?.isActive()) this.requestBegin(this.holdId);
|
|
192
|
+
}
|
|
165
193
|
}
|
|
166
194
|
}
|
|
167
195
|
|
package/src/session.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* is ever rewritten, so the rest of the buffer stays the user's.
|
|
6
6
|
*/
|
|
7
7
|
import { openMicrophone, type Microphone } from "./capture.ts";
|
|
8
|
-
import { MIN_PHRASE_MS, MIN_PHRASE_RMS, PARTIAL_INTERVAL_MS, msToSamples } from "./config.ts";
|
|
8
|
+
import { LISTENING_BLINK_MS, MIN_PHRASE_MS, MIN_PHRASE_RMS, PARTIAL_INTERVAL_MS, msToSamples } from "./config.ts";
|
|
9
9
|
import { trace } from "./debug.ts";
|
|
10
10
|
import { commitPartial, joinText, regionCursorOffset, renderRegion, withPartial, type Region } from "./region.ts";
|
|
11
11
|
import { Segmenter, rmsOf } from "./segmenter.ts";
|
|
@@ -16,31 +16,45 @@ export type SessionUi = {
|
|
|
16
16
|
/** Replace the editor text, place the cursor at `cursorOffset`, dim `partialLength` before it. */
|
|
17
17
|
write(text: string, cursorOffset: number, partialLength: number): void;
|
|
18
18
|
status(text: string | undefined): void;
|
|
19
|
+
/** Indicator text for a blink phase, already coloured. */
|
|
20
|
+
indicator(state: "loading" | "listening", filled: boolean): string;
|
|
19
21
|
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
const MIN_PHRASE_SAMPLES = msToSamples(MIN_PHRASE_MS);
|
|
23
25
|
|
|
24
|
-
/** Whisper ends a decode with a full stop; mid-session that is not the sentence end. */
|
|
25
|
-
const TRAILING_PERIOD = /\.\s*$/;
|
|
26
|
-
|
|
27
26
|
export class DictationSession {
|
|
28
|
-
private readonly stt: Stt
|
|
27
|
+
private readonly stt: Promise<Stt>;
|
|
29
28
|
private readonly ui: SessionUi;
|
|
30
29
|
private readonly openMic: typeof openMicrophone;
|
|
31
30
|
private region: Region | undefined;
|
|
32
31
|
private mic: Microphone | undefined;
|
|
33
32
|
private segmenter = new Segmenter();
|
|
34
33
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
34
|
+
private listeningTimer: ReturnType<typeof setInterval> | undefined;
|
|
35
|
+
private listeningFilled = true;
|
|
36
|
+
/** The recognizer is still loading until this flips; the indicator says so. */
|
|
37
|
+
private modelReady = false;
|
|
35
38
|
private pending = 0;
|
|
39
|
+
private inFlight = new Set<Promise<void>>();
|
|
40
|
+
private stopPromise: Promise<boolean> | undefined;
|
|
36
41
|
private stopped = true;
|
|
37
42
|
/** Characters committed during this session, to tell a real result from silence. */
|
|
38
43
|
private committedChars = 0;
|
|
39
44
|
|
|
40
|
-
constructor(stt: Stt
|
|
41
|
-
this.stt = stt;
|
|
45
|
+
constructor(stt: Stt | Promise<Stt>, ui: SessionUi, openMic: typeof openMicrophone = openMicrophone) {
|
|
46
|
+
this.stt = Promise.resolve(stt);
|
|
42
47
|
this.ui = ui;
|
|
43
48
|
this.openMic = openMic;
|
|
49
|
+
|
|
50
|
+
void this.stt.then(
|
|
51
|
+
() => {
|
|
52
|
+
this.modelReady = true;
|
|
53
|
+
if (!this.stopped) this.writeListening();
|
|
54
|
+
},
|
|
55
|
+
// The first decode or stop reports a load failure; keep it handled.
|
|
56
|
+
() => {},
|
|
57
|
+
);
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
async start(region: Region): Promise<void> {
|
|
@@ -49,7 +63,7 @@ export class DictationSession {
|
|
|
49
63
|
this.region = region;
|
|
50
64
|
this.segmenter = new Segmenter();
|
|
51
65
|
this.committedChars = 0;
|
|
52
|
-
this.
|
|
66
|
+
this.startListening();
|
|
53
67
|
|
|
54
68
|
try {
|
|
55
69
|
this.mic = await this.openMic(
|
|
@@ -58,7 +72,7 @@ export class DictationSession {
|
|
|
58
72
|
);
|
|
59
73
|
} catch (error) {
|
|
60
74
|
this.stopped = true;
|
|
61
|
-
this.
|
|
75
|
+
this.stopListening();
|
|
62
76
|
this.ui.notify(`Dictation: microphone unavailable (${String(error)})`, "error");
|
|
63
77
|
return;
|
|
64
78
|
}
|
|
@@ -70,7 +84,7 @@ export class DictationSession {
|
|
|
70
84
|
return;
|
|
71
85
|
}
|
|
72
86
|
|
|
73
|
-
this.timer = setInterval(() =>
|
|
87
|
+
this.timer = setInterval(() => this.track(this.preview()), PARTIAL_INTERVAL_MS);
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
isActive(): boolean {
|
|
@@ -78,7 +92,14 @@ export class DictationSession {
|
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
/** Decode what is still open, commit it, and close the session. Returns whether anything was transcribed. */
|
|
81
|
-
|
|
95
|
+
stop(): Promise<boolean> {
|
|
96
|
+
// Callers can race (a failure and a release); they must share one shutdown
|
|
97
|
+
// so the final write cannot land in a newer session's buffer.
|
|
98
|
+
this.stopPromise ??= this.finish();
|
|
99
|
+
return this.stopPromise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async finish(): Promise<boolean> {
|
|
82
103
|
if (this.stopped) return false;
|
|
83
104
|
this.stopped = true;
|
|
84
105
|
this.clearTimer();
|
|
@@ -88,17 +109,28 @@ export class DictationSession {
|
|
|
88
109
|
const tail = this.segmenter.open();
|
|
89
110
|
this.segmenter.reset();
|
|
90
111
|
if (tail.length >= MIN_PHRASE_SAMPLES && rmsOf(tail) >= MIN_PHRASE_RMS) {
|
|
91
|
-
await this.decodePhrase(tail
|
|
112
|
+
await this.decodePhrase(tail);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A phrase that closed just before the release may still be decoding. Its
|
|
116
|
+
// samples left the segmenter, so dropping the result loses that speech.
|
|
117
|
+
while (this.inFlight.size > 0) {
|
|
118
|
+
await Promise.allSettled([...this.inFlight]);
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
//
|
|
121
|
+
// If the tail was too short to decode, or its decode returned nothing, the
|
|
122
|
+
// preview is the only transcript of that audio. Commit it, do not wipe it.
|
|
95
123
|
if (this.region && this.region.partial.length > 0) {
|
|
96
|
-
this.
|
|
97
|
-
this.
|
|
124
|
+
this.commitText(this.region.partial);
|
|
125
|
+
if (this.region.partial.length > 0) {
|
|
126
|
+
this.region = { ...this.region, partial: "" };
|
|
127
|
+
this.write();
|
|
128
|
+
}
|
|
98
129
|
}
|
|
99
130
|
|
|
100
131
|
const committed = this.committedChars > 0;
|
|
101
|
-
this.
|
|
132
|
+
trace(`stop committed=${String(committed)} chars=${this.committedChars}`);
|
|
133
|
+
this.stopListening();
|
|
102
134
|
this.region = undefined;
|
|
103
135
|
return committed;
|
|
104
136
|
}
|
|
@@ -116,7 +148,7 @@ export class DictationSession {
|
|
|
116
148
|
const cleaned = joinText(this.region.before, this.region.after);
|
|
117
149
|
this.ui.write(cleaned, this.region.before.length, 0);
|
|
118
150
|
}
|
|
119
|
-
this.
|
|
151
|
+
this.stopListening();
|
|
120
152
|
this.region = undefined;
|
|
121
153
|
}
|
|
122
154
|
|
|
@@ -125,18 +157,18 @@ export class DictationSession {
|
|
|
125
157
|
const closed = this.segmenter.add(samples);
|
|
126
158
|
if (!closed) return;
|
|
127
159
|
if (rmsOf(closed.samples) < MIN_PHRASE_RMS) return;
|
|
128
|
-
|
|
129
|
-
// its trailing full stop is Whisper's, not the speaker's.
|
|
130
|
-
void this.decodePhrase(closed.samples, false);
|
|
160
|
+
this.track(this.decodePhrase(closed.samples));
|
|
131
161
|
}
|
|
132
162
|
|
|
133
|
-
private async decodePhrase(samples: Float32Array
|
|
163
|
+
private async decodePhrase(samples: Float32Array): Promise<void> {
|
|
134
164
|
this.pending++;
|
|
135
165
|
try {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
const stt = await this.stt;
|
|
167
|
+
const raw = await stt.recognize(samples);
|
|
168
|
+
trace(`phrase ${samples.length} samples -> ${JSON.stringify(raw)}`);
|
|
169
|
+
// Keep the model's punctuation: a pause usually is a real boundary, and
|
|
170
|
+
// removing a mark here also turned "..." into "..".
|
|
171
|
+
this.commitText(cleanTranscript(raw));
|
|
140
172
|
} catch (error) {
|
|
141
173
|
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
142
174
|
} finally {
|
|
@@ -152,7 +184,8 @@ export class DictationSession {
|
|
|
152
184
|
|
|
153
185
|
this.pending++;
|
|
154
186
|
try {
|
|
155
|
-
const
|
|
187
|
+
const stt = await this.stt;
|
|
188
|
+
const text = cleanTranscript(await stt.recognize(open));
|
|
156
189
|
if (this.stopped || !this.region) return;
|
|
157
190
|
this.region = withPartial(this.region, text);
|
|
158
191
|
this.write();
|
|
@@ -176,9 +209,45 @@ export class DictationSession {
|
|
|
176
209
|
this.ui.write(renderRegion(this.region), regionCursorOffset(this.region), this.region.partial.length);
|
|
177
210
|
}
|
|
178
211
|
|
|
212
|
+
/** Alternate a filled red circle with an empty one while the session is live. */
|
|
213
|
+
private startListening(): void {
|
|
214
|
+
this.stopListening();
|
|
215
|
+
this.listeningFilled = true;
|
|
216
|
+
this.writeListening();
|
|
217
|
+
this.listeningTimer = setInterval(() => {
|
|
218
|
+
this.listeningFilled = !this.listeningFilled;
|
|
219
|
+
this.writeListening();
|
|
220
|
+
}, LISTENING_BLINK_MS);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private writeListening(): void {
|
|
224
|
+
this.ui.status(this.ui.indicator(this.modelReady ? "listening" : "loading", this.listeningFilled));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private stopListening(): void {
|
|
228
|
+
if (this.listeningTimer) {
|
|
229
|
+
clearInterval(this.listeningTimer);
|
|
230
|
+
this.listeningTimer = undefined;
|
|
231
|
+
}
|
|
232
|
+
this.ui.status(undefined);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Keep the speech already captured; a failed stream must not eat the transcript. */
|
|
179
236
|
private fail(error: Error): void {
|
|
180
237
|
this.ui.notify(`Dictation: ${error.message}`, "error");
|
|
181
|
-
this.
|
|
238
|
+
void this.stop().catch((stopError: unknown) => {
|
|
239
|
+
trace(`stop after failure failed: ${String(stopError)}`);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Keep the decode visible to `stop` so it can wait for the commit. */
|
|
244
|
+
private track(work: Promise<void>): void {
|
|
245
|
+
const settled = work.then(
|
|
246
|
+
() => {},
|
|
247
|
+
() => {},
|
|
248
|
+
);
|
|
249
|
+
this.inFlight.add(settled);
|
|
250
|
+
void settled.finally(() => this.inFlight.delete(settled));
|
|
182
251
|
}
|
|
183
252
|
|
|
184
253
|
private clearTimer(): void {
|