@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.
Files changed (33) hide show
  1. package/electron-vite.config.ts +5 -0
  2. package/out/headless/index.mjs +2 -1
  3. package/out/headless/index.mjs.map +2 -2
  4. package/out/main/index.js +3 -0
  5. package/out/renderer/assets/{cssMode-D9-xaWSI.js → cssMode-eTXVdAkZ.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-CoRAVxnv.js → freemarker2-B5BKaiK4.js} +1 -1
  7. package/out/renderer/assets/{handlebars-B0p9Wgkw.js → handlebars-BIdLd2wU.js} +1 -1
  8. package/out/renderer/assets/{html-D_XFJJtO.js → html-BXL4cnLS.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-naWw6PWr.js → htmlMode-46N3XG2c.js} +3 -3
  10. package/out/renderer/assets/{index-ezC-iarf.css → index-Cf-RsxoC.css} +163 -0
  11. package/out/renderer/assets/{index-DBt_rov1.js → index-dRvutfbl.js} +572 -107
  12. package/out/renderer/assets/{javascript-DDLsFUr-.js → javascript-n_iZZzDX.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-Ixhcm5I6.js → jsonMode-DXDczSNu.js} +3 -3
  14. package/out/renderer/assets/{liquid-BHgSYEHk.js → liquid-B1QweUh7.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-ClbEdD0U.js → lspLanguageFeatures-DqzMqkRk.js} +1 -1
  16. package/out/renderer/assets/{mdx-DMngMjHR.js → mdx-BCv8lm5e.js} +1 -1
  17. package/out/renderer/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
  18. package/out/renderer/assets/{python-D_czoeY2.js → python-BLNzYwDv.js} +1 -1
  19. package/out/renderer/assets/{razor-CLMDGvL7.js → razor-CvAww8bG.js} +1 -1
  20. package/out/renderer/assets/transformers.web-DtSCnG36.js +33668 -0
  21. package/out/renderer/assets/{tsMode-EIuSGG42.js → tsMode-C7m6Kr5E.js} +1 -1
  22. package/out/renderer/assets/{typescript-DQkV4kKA.js → typescript-DhPw4VVg.js} +1 -1
  23. package/out/renderer/assets/{xml-DJ0OOQTu.js → xml-B0WLFJ2U.js} +1 -1
  24. package/out/renderer/assets/{yaml-DxX26XLN.js → yaml-BWyn9Wd7.js} +1 -1
  25. package/out/renderer/index.html +2 -2
  26. package/package.json +2 -1
  27. package/src/main/index.ts +7 -0
  28. package/src/renderer/App.tsx +41 -1
  29. package/src/renderer/components/FloatingMic.tsx +128 -0
  30. package/src/renderer/components/TerminalPanel.tsx +6 -0
  31. package/src/renderer/components/VoiceInput.tsx +321 -0
  32. package/src/renderer/lib/localWhisper.ts +88 -0
  33. 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
+ }
@@ -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;