wip: incremental ASR outputs in realtime, few bugs
This commit is contained in:
1 parent
65855ace6c
commit
ecd686073e
1 file changed
+41
-17
+41
-17
@@ -309,10 +309,33 @@ export default function asrExtension(pi: ExtensionAPI) {
|
|||||||
* gives no key-release event for hold-to-talk without the Kitty keyboard protocol.
|
* gives no key-release event for hold-to-talk without the Kitty keyboard protocol.
|
||||||
* Shared module state so the /dictate command and the ctrl+shift+d shortcut drive one session.
|
* Shared module state so the /dictate command and the ctrl+shift+d shortcut drive one session.
|
||||||
*/
|
*/
|
||||||
let active: { rec: MicRecording; submit: boolean; base: string } | null = null;
|
let active: { rec: MicRecording; submit: boolean; ourTail: string } | null = null;
|
||||||
let starting = false;
|
let starting = false;
|
||||||
/** Compose the composer text from the user's pre-existing draft + the dictated tail (idempotent: never appended). */
|
/**
|
||||||
const joinText = (base: string, t: string): string => { const tail = t.trim(); return base ? (tail ? `${base} ${tail}` : base) : tail; };
|
* Append-as-you-go composer. Dictation owns ONLY a suffix at the very end of the editor (the words
|
||||||
|
* heard so far). Each refresh strips exactly the suffix we appended last time from the *current*
|
||||||
|
* text and re-appends the grown tail — so we never overwrite the user's draft and never revive text
|
||||||
|
* they deleted: we derive from the live editor, not a start-of-session snapshot.
|
||||||
|
*/
|
||||||
|
function paintTail(ctx: ExtensionContext, prevOurs: string, tail: string): string {
|
||||||
|
const cur = ctx.ui.getEditorText() ?? "";
|
||||||
|
let prefix = cur;
|
||||||
|
if (prevOurs) {
|
||||||
|
if (cur.endsWith(prevOurs)) prefix = cur.slice(0, cur.length - prevOurs.length);
|
||||||
|
else { const i = cur.lastIndexOf(prevOurs); if (i >= 0) prefix = cur.slice(0, i) + cur.slice(i + prevOurs.length); }
|
||||||
|
}
|
||||||
|
const t = tail.trim();
|
||||||
|
const ours = t ? (prefix === "" || /\s$/.test(prefix) ? "" : " ") + t : "";
|
||||||
|
ctx.ui.setEditorText(prefix + ours);
|
||||||
|
return ours;
|
||||||
|
}
|
||||||
|
/** Drop the suffix we own, leaving the user's own text untouched (cancel / no-speech / submit). */
|
||||||
|
function stripTail(ctx: ExtensionContext, ours: string): void {
|
||||||
|
if (!ours) return;
|
||||||
|
const cur = ctx.ui.getEditorText() ?? "";
|
||||||
|
if (cur.endsWith(ours)) ctx.ui.setEditorText(cur.slice(0, cur.length - ours.length));
|
||||||
|
else { const i = cur.lastIndexOf(ours); if (i >= 0) ctx.ui.setEditorText(cur.slice(0, i) + cur.slice(i + ours.length)); }
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveDevice(d?: string | null): Promise<string | null> {
|
async function resolveDevice(d?: string | null): Promise<string | null> {
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
@@ -329,18 +352,18 @@ export default function asrExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
// second press while live => stop, flush, finalise the composer with the deduped transcript.
|
// second press while live => stop, flush, finalise the composer with the deduped transcript.
|
||||||
if (active) {
|
if (active) {
|
||||||
const { rec, submit, base } = active; active = null;
|
const { rec, submit, ourTail } = active; active = null;
|
||||||
ctx.ui.setStatus("asr", undefined);
|
ctx.ui.setStatus("asr", undefined);
|
||||||
try {
|
try {
|
||||||
const res = await rec.stop();
|
const res = await rec.stop();
|
||||||
const text = res.text.trim();
|
const text = res.text.trim();
|
||||||
if (rec.error) { ctx.ui.setEditorText(base); ctx.ui.notify(`dictation error: ${rec.error}`, "error"); return; }
|
if (rec.error) { stripTail(ctx, ourTail); ctx.ui.notify(`dictation error: ${rec.error}`, "error"); return; }
|
||||||
if (!text) { ctx.ui.setEditorText(base); ctx.ui.notify("no speech detected (speak a little longer, or run asr_health)", "warning"); return; }
|
if (!text) { stripTail(ctx, ourTail); ctx.ui.notify("no speech detected (speak a little longer, or run asr_health)", "warning"); return; }
|
||||||
if (submit) { ctx.ui.setEditorText(base); pi.sendUserMessage(text); }
|
if (submit) { stripTail(ctx, ourTail); pi.sendUserMessage(text); }
|
||||||
else ctx.ui.setEditorText(joinText(base, text)); // identical to the last live paint — replace, not append
|
else paintTail(ctx, ourTail, text); // swap our live suffix for the finalised text; user's own text is untouched
|
||||||
ctx.ui.notify(`dictated ${wordCount(text)} words (${res.seconds}s) → ${submit ? "sent" : "prompt"}`, "info");
|
ctx.ui.notify(`dictated ${wordCount(text)} words (${res.seconds}s) → ${submit ? "sent" : "added"}`, "info");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
rec.cancel(); ctx.ui.setEditorText(base);
|
rec.cancel(); stripTail(ctx, ourTail);
|
||||||
ctx.ui.notify(`dictation failed: ${e?.message ?? e}`, "error");
|
ctx.ui.notify(`dictation failed: ${e?.message ?? e}`, "error");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -353,16 +376,17 @@ export default function asrExtension(pi: ExtensionAPI) {
|
|||||||
try { device = await resolveDevice(opts.device); }
|
try { device = await resolveDevice(opts.device); }
|
||||||
catch (e: any) { starting = false; ctx.ui.notify(`dictation: ${e?.message ?? e}`, "error"); return; }
|
catch (e: any) { starting = false; ctx.ui.notify(`dictation: ${e?.message ?? e}`, "error"); return; }
|
||||||
|
|
||||||
const base = (ctx.ui.getEditorText() ?? "").replace(/\s+$/, "");
|
// No start-of-session snapshot: dictation appends to the *live* editor text (paintTail/stripTail),
|
||||||
|
// so any edits or deletions the user makes while we record are preserved and never revived.
|
||||||
const client = new RealtimeAsrClient({ language, punctuation: true });
|
const client = new RealtimeAsrClient({ language, punctuation: true });
|
||||||
ctx.ui.setStatus("asr", "● listening …");
|
ctx.ui.setStatus("asr", "● listening …");
|
||||||
let rec: MicRecording;
|
let rec: MicRecording;
|
||||||
try {
|
try {
|
||||||
rec = await client.recordMic({
|
rec = await client.recordMic({
|
||||||
device,
|
device,
|
||||||
// stream word-by-word into the composer: setEditorText REPLACES (never appends), and the client's
|
// stream word-by-word: paintTail owns only our appended suffix, so the user's draft is preserved
|
||||||
// liveText is a per-segment cumulative partial, so each paint just overwrites the growing tail — no dupes.
|
// and anything they delete stays deleted (no snapshot is ever written back). liveText is cumulative.
|
||||||
onLive: (live) => { if (active) { const tail = live.trim(); ctx.ui.setEditorText(joinText(base, tail)); ctx.ui.setStatus("asr", `● ${tail.slice(-70) || "…listening"}`); } },
|
onLive: (live) => { if (active) { active.ourTail = paintTail(ctx, active.ourTail, live); ctx.ui.setStatus("asr", `● ${live.trim().slice(-70) || "…listening"}`); } },
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
starting = false; ctx.ui.setStatus("asr", undefined);
|
starting = false; ctx.ui.setStatus("asr", undefined);
|
||||||
@@ -370,12 +394,12 @@ export default function asrExtension(pi: ExtensionAPI) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
starting = false;
|
starting = false;
|
||||||
active = { rec, submit: !!opts.submit, base };
|
active = { rec, submit: !!opts.submit, ourTail: "" };
|
||||||
ctx.ui.setEditorText(joinText(base, rec.liveText())); // show current state immediately (deltas are cumulative, so nothing is lost)
|
active.ourTail = paintTail(ctx, "", rec.liveText()); // reflect anything already recognised without overwriting the user's draft
|
||||||
ctx.ui.notify(`🎙 recording${device ? ` from “${device}”` : " (default mic)"} — speech streams into the prompt; ctrl+shift+d or /dictate to stop`, "info");
|
ctx.ui.notify(`🎙 recording${device ? ` from “${device}”` : " (default mic)"} — speech streams into the prompt; ctrl+shift+d or /dictate to stop`, "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
pi.registerShortcut("ctrl+shift+d", {
|
pi.registerShortcut("ctrl+shift+m", {
|
||||||
description: "Toggle macOS live dictation (press to start, press again to stop and paste into the prompt)",
|
description: "Toggle macOS live dictation (press to start, press again to stop and paste into the prompt)",
|
||||||
handler: async (ctx) => { await toggleDictation(ctx); },
|
handler: async (ctx) => { await toggleDictation(ctx); },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user