@phenx-inc/ctlsurf 0.7.0 → 0.9.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/out/headless/index.mjs +169 -10
- package/out/headless/index.mjs.map +2 -2
- package/out/main/index.js +175 -9
- package/out/preload/index.js +10 -0
- package/out/renderer/assets/{cssMode-eTXVdAkZ.js → cssMode-BdYp0fp9.js} +3 -3
- package/out/renderer/assets/{freemarker2-B5BKaiK4.js → freemarker2-Cya-9zr-.js} +1 -1
- package/out/renderer/assets/{handlebars-BIdLd2wU.js → handlebars-EJs9W5Ub.js} +1 -1
- package/out/renderer/assets/{html-BXL4cnLS.js → html-Ex4qy2lh.js} +1 -1
- package/out/renderer/assets/{htmlMode-46N3XG2c.js → htmlMode-CCm0aJ6C.js} +3 -3
- package/out/renderer/assets/{index-dRvutfbl.js → index-Cj7K9-Ba.js} +823 -137
- package/out/renderer/assets/{index-Cf-RsxoC.css → index-nvsNYP5c.css} +229 -0
- package/out/renderer/assets/{javascript-n_iZZzDX.js → javascript-B2niCouI.js} +2 -2
- package/out/renderer/assets/{jsonMode-DXDczSNu.js → jsonMode-BgCUHaFo.js} +3 -3
- package/out/renderer/assets/{liquid-B1QweUh7.js → liquid-DOQmSo4G.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-DqzMqkRk.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
- package/out/renderer/assets/{mdx-BCv8lm5e.js → mdx-DuKdGNKE.js} +1 -1
- package/out/renderer/assets/{python-BLNzYwDv.js → python-BUa4XFG-.js} +1 -1
- package/out/renderer/assets/{razor-CvAww8bG.js → razor-BUw8jho0.js} +1 -1
- package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
- package/out/renderer/assets/{tsMode-C7m6Kr5E.js → tsMode-8FLRnobw.js} +1 -1
- package/out/renderer/assets/{typescript-DhPw4VVg.js → typescript-rdwAjCi4.js} +1 -1
- package/out/renderer/assets/{xml-B0WLFJ2U.js → xml-HOzT59Qg.js} +1 -1
- package/out/renderer/assets/{yaml-BWyn9Wd7.js → yaml-B81JfLsW.js} +1 -1
- package/out/renderer/index.html +64 -2
- package/out/repro-toggle.mjs +305 -0
- package/package.json +1 -1
- package/src/main/index.ts +9 -0
- package/src/main/orchestrator.ts +51 -10
- package/src/main/transcripts.ts +134 -0
- package/src/preload/index.ts +14 -0
- package/src/renderer/App.tsx +30 -11
- package/src/renderer/components/FloatingMic.tsx +48 -0
- package/src/renderer/components/SpeakControls.tsx +235 -0
- package/src/renderer/components/VoiceInput.tsx +183 -140
- package/src/renderer/index.html +62 -0
- package/src/renderer/lib/localWhisper.ts +94 -6
- package/src/renderer/lib/speech.ts +317 -0
- package/src/renderer/styles.css +229 -0
package/src/preload/index.ts
CHANGED
|
@@ -109,6 +109,20 @@ const api = {
|
|
|
109
109
|
setLogChat: (enabled: boolean): Promise<{ enabled: boolean }> =>
|
|
110
110
|
ipcRenderer.invoke('logchat:set', enabled),
|
|
111
111
|
|
|
112
|
+
// Speak agent replies (global, Electron-only)
|
|
113
|
+
getSpeakReplies: (): Promise<{ enabled: boolean }> =>
|
|
114
|
+
ipcRenderer.invoke('speak:get'),
|
|
115
|
+
setSpeakReplies: (enabled: boolean): Promise<{ enabled: boolean }> =>
|
|
116
|
+
ipcRenderer.invoke('speak:set', enabled),
|
|
117
|
+
onAgentMessage: (callback: (text: string) => void) => {
|
|
118
|
+
const listener = (_event: Electron.IpcRendererEvent, text: string) => callback(text)
|
|
119
|
+
ipcRenderer.on('agent:message', listener)
|
|
120
|
+
return () => ipcRenderer.removeListener('agent:message', listener)
|
|
121
|
+
},
|
|
122
|
+
// Latest assistant reply from the active tab's transcript (null if none).
|
|
123
|
+
getLastReply: (): Promise<string | null> =>
|
|
124
|
+
ipcRenderer.invoke('agent:getLastReply'),
|
|
125
|
+
|
|
112
126
|
// Filesystem
|
|
113
127
|
readDir: (dirPath: string): Promise<Array<{ name: string; path: string; isDirectory: boolean }>> =>
|
|
114
128
|
ipcRenderer.invoke('fs:readDir', dirPath),
|
package/src/renderer/App.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
2
|
import { TerminalPanel, destroyTerminal, focusTerminal } from './components/TerminalPanel'
|
|
3
3
|
import { FloatingMic } from './components/FloatingMic'
|
|
4
|
+
import { SpeakControls } from './components/SpeakControls'
|
|
4
5
|
import { CtlsurfPanel } from './components/CtlsurfPanel'
|
|
5
6
|
import { EditorPanel } from './components/EditorPanel'
|
|
6
7
|
import { AgentPicker } from './components/AgentPicker'
|
|
@@ -56,6 +57,10 @@ declare global {
|
|
|
56
57
|
setTracking: (enabled: boolean) => Promise<{ active: boolean }>
|
|
57
58
|
getLogChat: () => Promise<{ enabled: boolean }>
|
|
58
59
|
setLogChat: (enabled: boolean) => Promise<{ enabled: boolean }>
|
|
60
|
+
getSpeakReplies: () => Promise<{ enabled: boolean }>
|
|
61
|
+
setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
|
|
62
|
+
onAgentMessage: (callback: (text: string) => void) => () => void
|
|
63
|
+
getLastReply: () => Promise<string | null>
|
|
59
64
|
createProject: () => Promise<{ ok: boolean; folder_id?: string; error?: string }>
|
|
60
65
|
getWebviewInfo: () => Promise<{
|
|
61
66
|
frontendUrl: string; pageUrl?: string; authenticated: boolean;
|
|
@@ -151,16 +156,22 @@ export default function App() {
|
|
|
151
156
|
|
|
152
157
|
useEffect(() => {
|
|
153
158
|
async function init() {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
try {
|
|
160
|
+
const list = await window.worker.listAgents()
|
|
161
|
+
setAgents(list)
|
|
162
|
+
const status = await window.worker.getWorkerStatus()
|
|
163
|
+
setWsStatus(status)
|
|
164
|
+
// Load initial cwd for the picker
|
|
165
|
+
const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath())
|
|
166
|
+
setCwd(initialCwd)
|
|
167
|
+
cwdRef.current = initialCwd
|
|
168
|
+
// Connected ctlsurf project name for the header (null until a worker registers)
|
|
169
|
+
window.worker.getProjectName().then(setProjectName).catch(() => { /* ignore */ })
|
|
170
|
+
} finally {
|
|
171
|
+
// Dismiss the boot splash painted by index.html now that startup IPC has
|
|
172
|
+
// settled. In `finally` so a failed IPC call never leaves it stuck on.
|
|
173
|
+
;(window as unknown as { __dismissSplash?: () => void }).__dismissSplash?.()
|
|
174
|
+
}
|
|
164
175
|
}
|
|
165
176
|
init()
|
|
166
177
|
}, [])
|
|
@@ -221,8 +232,15 @@ export default function App() {
|
|
|
221
232
|
const handleVoiceTranscript = useCallback((text: string) => {
|
|
222
233
|
const trimmed = text.trim()
|
|
223
234
|
if (!trimmed) return
|
|
224
|
-
|
|
235
|
+
// Send the text and the submit Enter as TWO separate writes. A long transcript
|
|
236
|
+
// glued to its trailing \r arrives as one fast burst, which the agent's input
|
|
237
|
+
// box treats as a paste and turns the carriage return into a literal newline
|
|
238
|
+
// instead of submitting. Delaying the Enter lets the text be consumed first so
|
|
239
|
+
// \r registers as a real submit keystroke. (80ms is the tunable knob if very
|
|
240
|
+
// long input ever needs more time to drain.)
|
|
241
|
+
window.worker.writePty(activeTabId, trimmed)
|
|
225
242
|
focusTerminal(activeTabId)
|
|
243
|
+
setTimeout(() => window.worker.writePty(activeTabId, '\r'), 80)
|
|
226
244
|
}, [activeTabId])
|
|
227
245
|
|
|
228
246
|
const cwdRef = useRef<string | null>(null)
|
|
@@ -504,6 +522,7 @@ export default function App() {
|
|
|
504
522
|
<line x1="8" y1="23" x2="16" y2="23" />
|
|
505
523
|
</svg>
|
|
506
524
|
</button>
|
|
525
|
+
<SpeakControls />
|
|
507
526
|
<span className="titlebar-separator" />
|
|
508
527
|
{agents.map(a => {
|
|
509
528
|
const activeTab = tabs.find(t => t.id === activeTabId)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { VoiceInput } from './VoiceInput'
|
|
3
|
+
import { speech } from '../lib/speech'
|
|
3
4
|
|
|
4
5
|
// A draggable, dismissable push-to-talk mic that floats over the panes. It wraps
|
|
5
6
|
// the same <VoiceInput> push-to-talk logic used in the titlebar; only the chrome
|
|
@@ -36,6 +37,29 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
|
|
|
36
37
|
// Pointer-to-element offset captured at drag start; null when not dragging.
|
|
37
38
|
const dragRef = useRef<{ dx: number; dy: number } | null>(null)
|
|
38
39
|
|
|
40
|
+
// Read-aloud (TTS) of the last agent reply. `speaking` mirrors the shared
|
|
41
|
+
// speech singleton; `note` is a transient message (e.g. nothing to read yet).
|
|
42
|
+
const [speaking, setSpeaking] = useState(false)
|
|
43
|
+
const [note, setNote] = useState<string | null>(null)
|
|
44
|
+
useEffect(() => speech.onActivity(setSpeaking), [])
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!note) return
|
|
47
|
+
const t = setTimeout(() => setNote(null), 3000)
|
|
48
|
+
return () => clearTimeout(t)
|
|
49
|
+
}, [note])
|
|
50
|
+
|
|
51
|
+
const onReadAloud = useCallback(async () => {
|
|
52
|
+
// Toggle: a second click while speaking stops it.
|
|
53
|
+
if (speaking) { speech.stop(); return }
|
|
54
|
+
// Prime the audio context from this click (synchronously, before the await)
|
|
55
|
+
// so neural playback isn't blocked by the autoplay policy.
|
|
56
|
+
speech.unlock()
|
|
57
|
+
let text: string | null = null
|
|
58
|
+
try { text = await window.worker.getLastReply() } catch { /* ignore */ }
|
|
59
|
+
if (text) speech.enqueue(text)
|
|
60
|
+
else setNote('No reply to read yet')
|
|
61
|
+
}, [speaking])
|
|
62
|
+
|
|
39
63
|
// Keep the button fully inside the viewport (used on drag, mount, and resize).
|
|
40
64
|
const clamp = useCallback((x: number, y: number): Pos => {
|
|
41
65
|
const el = elRef.current
|
|
@@ -123,6 +147,30 @@ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
|
|
|
123
147
|
</button>
|
|
124
148
|
</div>
|
|
125
149
|
<VoiceInput variant="floating" onTranscript={onTranscript} />
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className={`floating-mic-read ${speaking ? 'speaking' : ''}`}
|
|
153
|
+
// Don't let a click on the button start a drag.
|
|
154
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
155
|
+
onClick={onReadAloud}
|
|
156
|
+
title={speaking ? 'Stop reading' : 'Read last agent reply aloud'}
|
|
157
|
+
aria-label={speaking ? 'Stop reading' : 'Read last agent reply aloud'}
|
|
158
|
+
aria-pressed={speaking}
|
|
159
|
+
>
|
|
160
|
+
{speaking ? (
|
|
161
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
|
|
162
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
163
|
+
</svg>
|
|
164
|
+
) : (
|
|
165
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor"
|
|
166
|
+
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
167
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
168
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
169
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
170
|
+
</svg>
|
|
171
|
+
)}
|
|
172
|
+
</button>
|
|
173
|
+
{note && <div className="floating-mic-note">{note}</div>}
|
|
126
174
|
</div>
|
|
127
175
|
)
|
|
128
176
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
speech,
|
|
4
|
+
getEngine,
|
|
5
|
+
setEngine as persistEngine,
|
|
6
|
+
getVoiceURI,
|
|
7
|
+
setVoiceURI as persistVoiceURI,
|
|
8
|
+
getRate,
|
|
9
|
+
setRate as persistRate,
|
|
10
|
+
listWebVoices,
|
|
11
|
+
type TtsEngineId,
|
|
12
|
+
type TtsModelProgress,
|
|
13
|
+
} from '../lib/speech'
|
|
14
|
+
|
|
15
|
+
// Titlebar control for "speak agent replies" (Electron desktop only). A speaker
|
|
16
|
+
// toggle drives the main-process speakReplies setting (which runs the transcript
|
|
17
|
+
// tailer); a ▾ dropdown picks the engine, voice, and rate.
|
|
18
|
+
|
|
19
|
+
const SAMPLE = 'This is how spoken agent replies will sound.'
|
|
20
|
+
|
|
21
|
+
export function SpeakControls() {
|
|
22
|
+
const [enabled, setEnabled] = useState(false)
|
|
23
|
+
const [engine, setEngineState] = useState<TtsEngineId>(getEngine)
|
|
24
|
+
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
|
|
25
|
+
const [voiceURI, setVoiceURIState] = useState<string | null>(getVoiceURI)
|
|
26
|
+
const [rate, setRateState] = useState<number>(getRate)
|
|
27
|
+
const [showMenu, setShowMenu] = useState(false)
|
|
28
|
+
const [modelPct, setModelPct] = useState<number | null>(null)
|
|
29
|
+
const [speaking, setSpeaking] = useState(false)
|
|
30
|
+
const [error, setError] = useState<string | null>(null)
|
|
31
|
+
const wrapRef = useRef<HTMLDivElement>(null)
|
|
32
|
+
|
|
33
|
+
// Reflect the persisted main-process setting on mount.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let alive = true
|
|
36
|
+
window.worker.getSpeakReplies().then((r) => { if (alive) setEnabled(!!r.enabled) }).catch(() => {})
|
|
37
|
+
return () => { alive = false }
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
// Speak replies as they arrive. Main only forwards when the setting is on, so
|
|
41
|
+
// this is a no-op while disabled.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const off = window.worker.onAgentMessage((text) => speech.enqueue(text))
|
|
44
|
+
return off
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
// Surface neural-model download/transcribe progress on the button.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
speech.onModelProgress = (p: TtsModelProgress | null) => {
|
|
50
|
+
if (p && p.status === 'progress' && typeof p.progress === 'number') {
|
|
51
|
+
setModelPct(Math.min(100, Math.round(p.progress)))
|
|
52
|
+
} else if (!p) {
|
|
53
|
+
setModelPct(null)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
speech.onActivityChange = (a) => setSpeaking(a)
|
|
57
|
+
speech.onError = (msg) => setError(msg)
|
|
58
|
+
return () => {
|
|
59
|
+
speech.onModelProgress = null
|
|
60
|
+
speech.onActivityChange = null
|
|
61
|
+
speech.onError = null
|
|
62
|
+
}
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
// Auto-dismiss the error chip.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!error) return
|
|
68
|
+
const t = setTimeout(() => setError(null), 4000)
|
|
69
|
+
return () => clearTimeout(t)
|
|
70
|
+
}, [error])
|
|
71
|
+
|
|
72
|
+
// Web Speech voices populate asynchronously.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const load = () => setVoices(listWebVoices())
|
|
75
|
+
load()
|
|
76
|
+
if (typeof speechSynthesis !== 'undefined') {
|
|
77
|
+
speechSynthesis.addEventListener('voiceschanged', load)
|
|
78
|
+
return () => speechSynthesis.removeEventListener('voiceschanged', load)
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
// Close the menu on an outside click.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!showMenu) return
|
|
85
|
+
const onDown = (e: MouseEvent) => {
|
|
86
|
+
if (!wrapRef.current?.contains(e.target as Node)) setShowMenu(false)
|
|
87
|
+
}
|
|
88
|
+
document.addEventListener('mousedown', onDown)
|
|
89
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
90
|
+
}, [showMenu])
|
|
91
|
+
|
|
92
|
+
const toggle = useCallback(async () => {
|
|
93
|
+
const next = !enabled
|
|
94
|
+
setEnabled(next)
|
|
95
|
+
// Prime the audio context from this click so neural playback isn't blocked
|
|
96
|
+
// by autoplay policy when a reply later arrives without a gesture.
|
|
97
|
+
if (next) speech.unlock()
|
|
98
|
+
else speech.stop()
|
|
99
|
+
try { await window.worker.setSpeakReplies(next) } catch { /* ignore */ }
|
|
100
|
+
}, [enabled])
|
|
101
|
+
|
|
102
|
+
const chooseEngine = useCallback((id: TtsEngineId) => {
|
|
103
|
+
persistEngine(id)
|
|
104
|
+
setEngineState(id)
|
|
105
|
+
speech.stop()
|
|
106
|
+
// Preload the neural model so the first reply isn't slowed by download.
|
|
107
|
+
if (id === 'neural') speech.warmup()
|
|
108
|
+
}, [])
|
|
109
|
+
|
|
110
|
+
const chooseVoice = useCallback((uri: string) => {
|
|
111
|
+
const v = uri || null
|
|
112
|
+
persistVoiceURI(v)
|
|
113
|
+
setVoiceURIState(v)
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
const changeRate = useCallback((r: number) => {
|
|
117
|
+
persistRate(r)
|
|
118
|
+
setRateState(r)
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
const testVoice = useCallback(() => {
|
|
122
|
+
speech.unlock()
|
|
123
|
+
speech.stop()
|
|
124
|
+
speech.enqueue(SAMPLE)
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
const title = enabled ? 'Spoken replies on — click to mute' : 'Speak agent replies (off)'
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="speak-controls" ref={wrapRef}>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className={`titlebar-btn titlebar-icon-btn speak-btn ${enabled ? 'active' : ''}`}
|
|
134
|
+
onClick={toggle}
|
|
135
|
+
title={title}
|
|
136
|
+
aria-label="Toggle spoken agent replies"
|
|
137
|
+
aria-pressed={enabled}
|
|
138
|
+
>
|
|
139
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor"
|
|
140
|
+
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
141
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
142
|
+
{enabled ? (
|
|
143
|
+
<>
|
|
144
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
145
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
146
|
+
</>
|
|
147
|
+
) : (
|
|
148
|
+
<line x1="23" y1="9" x2="17" y2="15" />
|
|
149
|
+
)}
|
|
150
|
+
</svg>
|
|
151
|
+
{modelPct !== null && <span className="speak-pct">{modelPct}%</span>}
|
|
152
|
+
</button>
|
|
153
|
+
{speaking && (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
className="titlebar-btn titlebar-icon-btn speak-stop"
|
|
157
|
+
onClick={() => speech.stop()}
|
|
158
|
+
title="Stop speaking"
|
|
159
|
+
aria-label="Stop speaking"
|
|
160
|
+
>
|
|
161
|
+
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true">
|
|
162
|
+
<rect x="5" y="5" width="14" height="14" rx="2" />
|
|
163
|
+
</svg>
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
className="titlebar-btn speak-caret"
|
|
169
|
+
onClick={() => setShowMenu((v) => !v)}
|
|
170
|
+
title="Voice options"
|
|
171
|
+
aria-label="Voice options"
|
|
172
|
+
aria-expanded={showMenu}
|
|
173
|
+
>
|
|
174
|
+
▾
|
|
175
|
+
</button>
|
|
176
|
+
|
|
177
|
+
{showMenu && (
|
|
178
|
+
<div className="speak-menu" role="menu">
|
|
179
|
+
<div className="speak-menu-head">Engine</div>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
className={`speak-menu-item ${engine === 'web' ? 'active' : ''}`}
|
|
183
|
+
onClick={() => chooseEngine('web')}
|
|
184
|
+
>
|
|
185
|
+
<span className="speak-menu-check">{engine === 'web' ? '✓' : ''}</span>
|
|
186
|
+
<span>System voice (instant)</span>
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
className={`speak-menu-item ${engine === 'neural' ? 'active' : ''}`}
|
|
191
|
+
onClick={() => chooseEngine('neural')}
|
|
192
|
+
>
|
|
193
|
+
<span className="speak-menu-check">{engine === 'neural' ? '✓' : ''}</span>
|
|
194
|
+
<span>Neural voice (downloads)</span>
|
|
195
|
+
</button>
|
|
196
|
+
|
|
197
|
+
{engine === 'web' && (
|
|
198
|
+
<>
|
|
199
|
+
<div className="speak-menu-head">Voice</div>
|
|
200
|
+
<select
|
|
201
|
+
className="speak-select"
|
|
202
|
+
value={voiceURI || ''}
|
|
203
|
+
onChange={(e) => chooseVoice(e.target.value)}
|
|
204
|
+
>
|
|
205
|
+
<option value="">System default</option>
|
|
206
|
+
{voices.map((v) => (
|
|
207
|
+
<option key={v.voiceURI} value={v.voiceURI}>
|
|
208
|
+
{v.name} {v.lang ? `(${v.lang})` : ''}
|
|
209
|
+
</option>
|
|
210
|
+
))}
|
|
211
|
+
</select>
|
|
212
|
+
</>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
<div className="speak-menu-head">Rate · {rate.toFixed(1)}×</div>
|
|
216
|
+
<input
|
|
217
|
+
className="speak-rate"
|
|
218
|
+
type="range"
|
|
219
|
+
min={0.5}
|
|
220
|
+
max={2}
|
|
221
|
+
step={0.1}
|
|
222
|
+
value={rate}
|
|
223
|
+
onChange={(e) => changeRate(Number(e.target.value))}
|
|
224
|
+
/>
|
|
225
|
+
|
|
226
|
+
<div className="speak-menu-row">
|
|
227
|
+
<button type="button" className="speak-menu-btn" onClick={testVoice}>Test</button>
|
|
228
|
+
<button type="button" className="speak-menu-btn" onClick={() => speech.stop()}>Stop</button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
{error && <div className="speak-error">{error}</div>}
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|