Repository navigation
Expand file tree
/
Copy pathtranscribe.ts
More file actions
132 lines (119 loc) · 4.44 KB
/
Copy pathtranscribe.ts
File metadata and controls
132 lines (119 loc) · 4.44 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { GrayImage } from "../../graphics/image";
import { getDefaultSmallFont, type BdfFont } from "../../graphics/bdffont";
import { writeTextToDownloads } from "../../native/file-access";
import { voiceControlBridge, type VoiceTranscriptEvent } from "../../native/voice-control";
import { Layer, type DashboardInputEvent, type LayerContext } from "../layers";
/**
* Live transcription view. Continuous mic capture is owned by the app wrapper
* (createTranscribeAppWindow); this layer accumulates the transcript, shows
* it, and saves it to Downloads on click.
*/
import { GESTURE_CLICK, GESTURE_DOUBLE_CLICK } from "../gestures";
export class TranscribeLayer implements Layer {
private status = "Listening...";
// Finalized utterances, plus the live (replace-semantics) partial appended
// when painting.
private finalizedText = "";
private liveText = "";
private saveNotice = "";
private unsubscribeTranscript: (() => void) | null = null;
private unsubscribeStatus: (() => void) | null = null;
start(requestRender: () => void): void {
this.unsubscribeTranscript = voiceControlBridge.onTranscript((event) => {
this.onTranscript(event);
requestRender();
});
this.unsubscribeStatus = voiceControlBridge.onStatus((state) => {
this.status = state.status;
requestRender();
});
}
paint(ctx: LayerContext): GrayImage {
const font = getDefaultSmallFont();
const { width, height } = ctx.stack.getBaseSize();
const image = new GrayImage(width, height, 0);
const text = this.displayText() || "Listening...";
const wrapped = wrapTranscribeText(font, text, width - 64);
image.drawText(font, 24, 20, "Transcribe", 200);
image.drawText(font, 24, 40, this.saveNotice || this.status, 110);
const bodyTop = 62;
const footerY = height - 18;
const bodyLines = Math.max(1, Math.floor((footerY - bodyTop) / 16));
const firstLine = Math.max(0, wrapped.length - bodyLines);
for (let index = firstLine; index < wrapped.length; index++) {
const y = bodyTop + (index - firstLine) * 16;
image.drawText(font, 32, y, wrapped[index]!, 230);
}
image.drawText(font, 24, footerY, `${GESTURE_CLICK} save ${GESTURE_DOUBLE_CLICK} back`, 110);
return image;
}
handleInput(event: DashboardInputEvent, ctx: LayerContext): void {
if (event.type === "click") {
this.saveTranscript();
ctx.actions.requestRender();
return;
}
if (event.type === "double-click") {
ctx.stack.pop();
}
}
onRemoved(): void {
this.unsubscribeTranscript?.();
this.unsubscribeTranscript = null;
this.unsubscribeStatus?.();
this.unsubscribeStatus = null;
}
private saveTranscript(): void {
const text = this.displayText().trim();
if (!text) {
this.saveNotice = "Nothing to save yet.";
return;
}
const filename = `transcript-${transcriptTimestamp()}.txt`;
const path = writeTextToDownloads(filename, `${text}\n`);
this.saveNotice = path ? `Saved ${filename}` : "Save failed (check file access).";
}
private displayText(): string {
if (!this.finalizedText) return this.liveText;
if (!this.liveText) return this.finalizedText;
return `${this.finalizedText} ${this.liveText}`;
}
private onTranscript(event: VoiceTranscriptEvent): void {
if (event.isFinal) {
const finalText = event.text.trim() || this.liveText.trim();
if (finalText) {
this.finalizedText = this.finalizedText ? `${this.finalizedText} ${finalText}` : finalText;
}
this.liveText = "";
} else {
// Replace semantics: the live partial is the whole current best transcript.
this.liveText = event.text.trim();
}
}
}
function transcriptTimestamp(): string {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, "0");
return (
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
);
}
function wrapTranscribeText(font: BdfFont, text: string, maxWidth: number): string[] {
const words = text.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let line = "";
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (line && font.measureText(candidate) > maxWidth) {
lines.push(line);
line = word;
} else {
line = candidate;
}
}
if (line) {
lines.push(line);
}
return lines.length ? lines : [""];
}