pi-push-to-talk 0.1.1 → 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 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, recognizer errors |
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
- sttPromise = Stt.create(undefined, (message) => ui?.setStatus("dictation", message)).then(
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.1.1",
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": [
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
- /** A space this soon after typing stays a space (pi-voicekit's value). */
37
- export const TYPING_COOLDOWN_MS = 400;
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
- const state = `${cursorOffset}:${partialLength}:${text}`;
37
- if (state === this.lastWrite) return;
38
- this.lastWrite = state;
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, TYPING_COOLDOWN_MS } from "./config.ts";
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, ui: SessionUi) => DictationSession;
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
- void this.begin();
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) this.stopping = this.session.stop();
114
- void this.stopping;
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
- private async begin(): Promise<void> {
118
- if (this.starting) return;
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 = await this.deps.getStt();
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, ui: SessionUi, openMic: typeof openMicrophone = openMicrophone) {
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.ui.status("MIC listening");
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.ui.status(undefined);
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(() => void this.preview(), PARTIAL_INTERVAL_MS);
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
- async stop(): Promise<boolean> {
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, true);
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
- // A preview of audio that was never committed must not stay in the buffer.
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.region = { ...this.region, partial: "" };
97
- this.write();
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.ui.status(undefined);
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.ui.status(undefined);
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
- // A phrase closing while the session is open is not the end of the thought:
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, final: boolean): Promise<void> {
163
+ private async decodePhrase(samples: Float32Array): Promise<void> {
134
164
  this.pending++;
135
165
  try {
136
- const raw = await this.stt.recognize(samples);
137
- trace(`phrase ${samples.length} samples final=${String(final)} -> ${JSON.stringify(raw)}`);
138
- const text = cleanTranscript(raw);
139
- this.commitText(final ? text : text.replace(TRAILING_PERIOD, ""));
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 text = cleanTranscript(await this.stt.recognize(open));
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.cancel();
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 {