@phenx-inc/ctlsurf 0.5.2 → 0.7.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/electron-vite.config.ts +5 -0
- package/out/headless/index.mjs +2 -1
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +3 -0
- package/out/renderer/assets/{cssMode-D9-xaWSI.js → cssMode-eTXVdAkZ.js} +3 -3
- package/out/renderer/assets/{freemarker2-CoRAVxnv.js → freemarker2-B5BKaiK4.js} +1 -1
- package/out/renderer/assets/{handlebars-B0p9Wgkw.js → handlebars-BIdLd2wU.js} +1 -1
- package/out/renderer/assets/{html-D_XFJJtO.js → html-BXL4cnLS.js} +1 -1
- package/out/renderer/assets/{htmlMode-naWw6PWr.js → htmlMode-46N3XG2c.js} +3 -3
- package/out/renderer/assets/{index-ezC-iarf.css → index-Cf-RsxoC.css} +163 -0
- package/out/renderer/assets/{index-DBt_rov1.js → index-dRvutfbl.js} +572 -107
- package/out/renderer/assets/{javascript-DDLsFUr-.js → javascript-n_iZZzDX.js} +2 -2
- package/out/renderer/assets/{jsonMode-Ixhcm5I6.js → jsonMode-DXDczSNu.js} +3 -3
- package/out/renderer/assets/{liquid-BHgSYEHk.js → liquid-B1QweUh7.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-ClbEdD0U.js → lspLanguageFeatures-DqzMqkRk.js} +1 -1
- package/out/renderer/assets/{mdx-DMngMjHR.js → mdx-BCv8lm5e.js} +1 -1
- package/out/renderer/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
- package/out/renderer/assets/{python-D_czoeY2.js → python-BLNzYwDv.js} +1 -1
- package/out/renderer/assets/{razor-CLMDGvL7.js → razor-CvAww8bG.js} +1 -1
- package/out/renderer/assets/transformers.web-DtSCnG36.js +33668 -0
- package/out/renderer/assets/{tsMode-EIuSGG42.js → tsMode-C7m6Kr5E.js} +1 -1
- package/out/renderer/assets/{typescript-DQkV4kKA.js → typescript-DhPw4VVg.js} +1 -1
- package/out/renderer/assets/{xml-DJ0OOQTu.js → xml-B0WLFJ2U.js} +1 -1
- package/out/renderer/assets/{yaml-DxX26XLN.js → yaml-BWyn9Wd7.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +2 -1
- package/src/main/index.ts +7 -0
- package/src/renderer/App.tsx +41 -1
- package/src/renderer/components/FloatingMic.tsx +128 -0
- package/src/renderer/components/TerminalPanel.tsx +6 -0
- package/src/renderer/components/VoiceInput.tsx +321 -0
- package/src/renderer/lib/localWhisper.ts +88 -0
- package/src/renderer/styles.css +163 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Local, offline speech-to-text via transformers.js (Whisper). Used as the
|
|
2
|
+
// fallback when the browser's Web Speech API is unavailable (the common case
|
|
3
|
+
// inside packaged Electron). The library and the ~75MB model are fetched lazily
|
|
4
|
+
// the first time a local transcription is requested, then cached by the runtime.
|
|
5
|
+
|
|
6
|
+
const MODEL = 'Xenova/whisper-base'
|
|
7
|
+
const TARGET_SAMPLE_RATE = 16000
|
|
8
|
+
|
|
9
|
+
export interface ModelProgress {
|
|
10
|
+
status: string
|
|
11
|
+
file?: string
|
|
12
|
+
progress?: number // 0–100 for the file currently downloading
|
|
13
|
+
loaded?: number
|
|
14
|
+
total?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// transformers.js is large and node-aware, so import it dynamically (Vite
|
|
18
|
+
// code-splits it into its own chunk that only loads on first local use).
|
|
19
|
+
type Transcriber = (audio: Float32Array, options?: Record<string, unknown>) => Promise<{ text: string } | Array<{ text: string }>>
|
|
20
|
+
|
|
21
|
+
let transcriberPromise: Promise<Transcriber> | null = null
|
|
22
|
+
|
|
23
|
+
export function isLocalModelLoading(): boolean {
|
|
24
|
+
return transcriberPromise !== null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loadTranscriber(onProgress?: (p: ModelProgress) => void): Promise<Transcriber> {
|
|
28
|
+
if (!transcriberPromise) {
|
|
29
|
+
transcriberPromise = (async () => {
|
|
30
|
+
const { pipeline, env } = await import('@huggingface/transformers')
|
|
31
|
+
// We don't ship model files; always fetch from the Hugging Face hub.
|
|
32
|
+
env.allowLocalModels = false
|
|
33
|
+
const common = { progress_callback: onProgress as never }
|
|
34
|
+
// Prefer WebGPU for speed when the runtime exposes it; otherwise use the
|
|
35
|
+
// default (WASM) backend, which always works. Guarding on navigator.gpu
|
|
36
|
+
// avoids a wasted partial download when there's no GPU path at all.
|
|
37
|
+
const hasWebGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
|
|
38
|
+
if (hasWebGpu) {
|
|
39
|
+
try {
|
|
40
|
+
return (await pipeline('automatic-speech-recognition', MODEL, { ...common, device: 'webgpu' })) as unknown as Transcriber
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn('[voice] WebGPU backend failed, falling back to WASM', err)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return (await pipeline('automatic-speech-recognition', MODEL, common)) as unknown as Transcriber
|
|
46
|
+
})()
|
|
47
|
+
// Allow a later retry if the first load fails (e.g. offline on first use).
|
|
48
|
+
transcriberPromise.catch(() => { transcriberPromise = null })
|
|
49
|
+
}
|
|
50
|
+
return transcriberPromise
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Decode a recorded audio Blob and resample it to mono 16kHz Float32 PCM, which
|
|
54
|
+
// is what Whisper expects. Returns null for empty/undecodable clips.
|
|
55
|
+
async function blobToPcm16k(blob: Blob): Promise<Float32Array | null> {
|
|
56
|
+
if (blob.size === 0) return null
|
|
57
|
+
const arrayBuffer = await blob.arrayBuffer()
|
|
58
|
+
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
|
59
|
+
const ctx = new AudioCtx()
|
|
60
|
+
let decoded: AudioBuffer
|
|
61
|
+
try {
|
|
62
|
+
decoded = await ctx.decodeAudioData(arrayBuffer)
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
} finally {
|
|
66
|
+
ctx.close()
|
|
67
|
+
}
|
|
68
|
+
const length = Math.ceil(decoded.duration * TARGET_SAMPLE_RATE)
|
|
69
|
+
if (length < 1) return null
|
|
70
|
+
const offline = new OfflineAudioContext(1, length, TARGET_SAMPLE_RATE)
|
|
71
|
+
const source = offline.createBufferSource()
|
|
72
|
+
source.buffer = decoded
|
|
73
|
+
source.connect(offline.destination)
|
|
74
|
+
source.start()
|
|
75
|
+
const rendered = await offline.startRendering()
|
|
76
|
+
return rendered.getChannelData(0)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function transcribeBlob(blob: Blob, onProgress?: (p: ModelProgress) => void): Promise<string> {
|
|
80
|
+
const transcriber = await loadTranscriber(onProgress)
|
|
81
|
+
const pcm = await blobToPcm16k(blob)
|
|
82
|
+
if (!pcm) return ''
|
|
83
|
+
const result = await transcriber(pcm)
|
|
84
|
+
const text = Array.isArray(result)
|
|
85
|
+
? result.map((r) => r.text).join(' ')
|
|
86
|
+
: result?.text
|
|
87
|
+
return (text || '').trim()
|
|
88
|
+
}
|
package/src/renderer/styles.css
CHANGED
|
@@ -570,6 +570,169 @@ html, body, #root {
|
|
|
570
570
|
line-height: 1;
|
|
571
571
|
}
|
|
572
572
|
|
|
573
|
+
/* Voice typing (push-to-talk mic) */
|
|
574
|
+
.voice-input-wrap {
|
|
575
|
+
position: relative;
|
|
576
|
+
display: inline-flex;
|
|
577
|
+
}
|
|
578
|
+
.voice-btn {
|
|
579
|
+
user-select: none;
|
|
580
|
+
-webkit-user-select: none;
|
|
581
|
+
touch-action: none;
|
|
582
|
+
}
|
|
583
|
+
.voice-btn:disabled {
|
|
584
|
+
opacity: 0.4;
|
|
585
|
+
cursor: not-allowed;
|
|
586
|
+
}
|
|
587
|
+
.voice-btn.listening {
|
|
588
|
+
color: #f7768e;
|
|
589
|
+
border-color: #f7768e;
|
|
590
|
+
background: #1f2335;
|
|
591
|
+
}
|
|
592
|
+
.voice-btn.busy {
|
|
593
|
+
color: #e0af68;
|
|
594
|
+
border-color: #e0af68;
|
|
595
|
+
background: #1f2335;
|
|
596
|
+
}
|
|
597
|
+
.voice-icon {
|
|
598
|
+
font-size: 13px;
|
|
599
|
+
line-height: 1;
|
|
600
|
+
}
|
|
601
|
+
.voice-dot {
|
|
602
|
+
width: 6px;
|
|
603
|
+
height: 6px;
|
|
604
|
+
border-radius: 50%;
|
|
605
|
+
display: inline-block;
|
|
606
|
+
vertical-align: middle;
|
|
607
|
+
background: #565f89;
|
|
608
|
+
}
|
|
609
|
+
.voice-dot.on {
|
|
610
|
+
background: #f7768e;
|
|
611
|
+
box-shadow: 0 0 4px #f7768e;
|
|
612
|
+
animation: voice-pulse 1s ease-in-out infinite;
|
|
613
|
+
}
|
|
614
|
+
.voice-dot.busy {
|
|
615
|
+
background: #e0af68;
|
|
616
|
+
box-shadow: 0 0 4px #e0af68;
|
|
617
|
+
animation: voice-pulse 0.8s ease-in-out infinite;
|
|
618
|
+
}
|
|
619
|
+
@keyframes voice-pulse {
|
|
620
|
+
0%, 100% { opacity: 1; }
|
|
621
|
+
50% { opacity: 0.3; }
|
|
622
|
+
}
|
|
623
|
+
.voice-chip {
|
|
624
|
+
position: absolute;
|
|
625
|
+
top: 100%;
|
|
626
|
+
right: 0;
|
|
627
|
+
margin-top: 6px;
|
|
628
|
+
max-width: 320px;
|
|
629
|
+
padding: 4px 9px;
|
|
630
|
+
border-radius: 5px;
|
|
631
|
+
font-size: 11px;
|
|
632
|
+
line-height: 1.3;
|
|
633
|
+
white-space: nowrap;
|
|
634
|
+
overflow: hidden;
|
|
635
|
+
text-overflow: ellipsis;
|
|
636
|
+
z-index: 50;
|
|
637
|
+
pointer-events: none;
|
|
638
|
+
border: 1px solid #3b3d57;
|
|
639
|
+
}
|
|
640
|
+
.voice-chip.listening {
|
|
641
|
+
background: #1f2335;
|
|
642
|
+
color: #a9b1d6;
|
|
643
|
+
}
|
|
644
|
+
.voice-chip.busy {
|
|
645
|
+
background: #1f2335;
|
|
646
|
+
color: #e0af68;
|
|
647
|
+
border-color: #e0af68;
|
|
648
|
+
}
|
|
649
|
+
.voice-chip.notice {
|
|
650
|
+
background: #1f2335;
|
|
651
|
+
color: #e0af68;
|
|
652
|
+
border-color: #e0af68;
|
|
653
|
+
white-space: normal;
|
|
654
|
+
}
|
|
655
|
+
.voice-chip.error {
|
|
656
|
+
background: #2d2030;
|
|
657
|
+
color: #f7768e;
|
|
658
|
+
border-color: #f7768e;
|
|
659
|
+
white-space: normal;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/* Floating push-to-talk mic (draggable, dismissable FAB) */
|
|
663
|
+
.floating-mic {
|
|
664
|
+
position: fixed;
|
|
665
|
+
z-index: 200;
|
|
666
|
+
display: flex;
|
|
667
|
+
flex-direction: column;
|
|
668
|
+
align-items: center;
|
|
669
|
+
gap: 4px;
|
|
670
|
+
padding: 4px 4px 6px;
|
|
671
|
+
background: #16161e;
|
|
672
|
+
border: 1px solid #3b3d57;
|
|
673
|
+
border-radius: 12px;
|
|
674
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
|
675
|
+
user-select: none;
|
|
676
|
+
-webkit-user-select: none;
|
|
677
|
+
}
|
|
678
|
+
.floating-mic-handle {
|
|
679
|
+
display: flex;
|
|
680
|
+
align-items: center;
|
|
681
|
+
justify-content: space-between;
|
|
682
|
+
width: 100%;
|
|
683
|
+
cursor: grab;
|
|
684
|
+
touch-action: none;
|
|
685
|
+
}
|
|
686
|
+
.floating-mic-handle:active { cursor: grabbing; }
|
|
687
|
+
.floating-mic-grip {
|
|
688
|
+
color: #565f89;
|
|
689
|
+
font-size: 12px;
|
|
690
|
+
line-height: 1;
|
|
691
|
+
padding: 0 2px;
|
|
692
|
+
}
|
|
693
|
+
.floating-mic-hide {
|
|
694
|
+
background: transparent;
|
|
695
|
+
border: none;
|
|
696
|
+
color: #565f89;
|
|
697
|
+
font-size: 15px;
|
|
698
|
+
line-height: 1;
|
|
699
|
+
cursor: pointer;
|
|
700
|
+
padding: 0 2px;
|
|
701
|
+
}
|
|
702
|
+
.floating-mic-hide:hover { color: #f7768e; }
|
|
703
|
+
|
|
704
|
+
.voice-btn-floating {
|
|
705
|
+
width: 48px;
|
|
706
|
+
height: 48px;
|
|
707
|
+
border-radius: 50%;
|
|
708
|
+
display: inline-flex;
|
|
709
|
+
align-items: center;
|
|
710
|
+
justify-content: center;
|
|
711
|
+
background: #2a2b3d;
|
|
712
|
+
border: 1px solid #3b3d57;
|
|
713
|
+
color: #a9b1d6;
|
|
714
|
+
cursor: pointer;
|
|
715
|
+
position: relative;
|
|
716
|
+
transition: all 0.15s;
|
|
717
|
+
}
|
|
718
|
+
.voice-btn-floating:hover { border-color: #565f89; }
|
|
719
|
+
.voice-btn-floating .voice-icon { font-size: 22px; }
|
|
720
|
+
.voice-btn-floating .voice-dot {
|
|
721
|
+
position: absolute;
|
|
722
|
+
top: 5px;
|
|
723
|
+
right: 5px;
|
|
724
|
+
}
|
|
725
|
+
/* Floating chip sits above the round button rather than below it. */
|
|
726
|
+
.voice-chip-floating {
|
|
727
|
+
top: auto;
|
|
728
|
+
bottom: 100%;
|
|
729
|
+
right: auto;
|
|
730
|
+
left: 50%;
|
|
731
|
+
transform: translateX(-50%);
|
|
732
|
+
margin-top: 0;
|
|
733
|
+
margin-bottom: 8px;
|
|
734
|
+
}
|
|
735
|
+
|
|
573
736
|
/* Editor panel */
|
|
574
737
|
.editor-panel {
|
|
575
738
|
display: flex;
|