@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.
Files changed (38) hide show
  1. package/out/headless/index.mjs +169 -10
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/main/index.js +175 -9
  4. package/out/preload/index.js +10 -0
  5. package/out/renderer/assets/{cssMode-eTXVdAkZ.js → cssMode-BdYp0fp9.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-B5BKaiK4.js → freemarker2-Cya-9zr-.js} +1 -1
  7. package/out/renderer/assets/{handlebars-BIdLd2wU.js → handlebars-EJs9W5Ub.js} +1 -1
  8. package/out/renderer/assets/{html-BXL4cnLS.js → html-Ex4qy2lh.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-46N3XG2c.js → htmlMode-CCm0aJ6C.js} +3 -3
  10. package/out/renderer/assets/{index-dRvutfbl.js → index-Cj7K9-Ba.js} +823 -137
  11. package/out/renderer/assets/{index-Cf-RsxoC.css → index-nvsNYP5c.css} +229 -0
  12. package/out/renderer/assets/{javascript-n_iZZzDX.js → javascript-B2niCouI.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-DXDczSNu.js → jsonMode-BgCUHaFo.js} +3 -3
  14. package/out/renderer/assets/{liquid-B1QweUh7.js → liquid-DOQmSo4G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-DqzMqkRk.js → lspLanguageFeatures-8SIcRAMh.js} +1 -1
  16. package/out/renderer/assets/{mdx-BCv8lm5e.js → mdx-DuKdGNKE.js} +1 -1
  17. package/out/renderer/assets/{python-BLNzYwDv.js → python-BUa4XFG-.js} +1 -1
  18. package/out/renderer/assets/{razor-CvAww8bG.js → razor-BUw8jho0.js} +1 -1
  19. package/out/renderer/assets/{transformers.web-DtSCnG36.js → transformers.web-_PuZoIho.js} +824 -19
  20. package/out/renderer/assets/{tsMode-C7m6Kr5E.js → tsMode-8FLRnobw.js} +1 -1
  21. package/out/renderer/assets/{typescript-DhPw4VVg.js → typescript-rdwAjCi4.js} +1 -1
  22. package/out/renderer/assets/{xml-B0WLFJ2U.js → xml-HOzT59Qg.js} +1 -1
  23. package/out/renderer/assets/{yaml-BWyn9Wd7.js → yaml-B81JfLsW.js} +1 -1
  24. package/out/renderer/index.html +64 -2
  25. package/out/repro-toggle.mjs +305 -0
  26. package/package.json +1 -1
  27. package/src/main/index.ts +9 -0
  28. package/src/main/orchestrator.ts +51 -10
  29. package/src/main/transcripts.ts +134 -0
  30. package/src/preload/index.ts +14 -0
  31. package/src/renderer/App.tsx +30 -11
  32. package/src/renderer/components/FloatingMic.tsx +48 -0
  33. package/src/renderer/components/SpeakControls.tsx +235 -0
  34. package/src/renderer/components/VoiceInput.tsx +183 -140
  35. package/src/renderer/index.html +62 -0
  36. package/src/renderer/lib/localWhisper.ts +94 -6
  37. package/src/renderer/lib/speech.ts +317 -0
  38. package/src/renderer/styles.css +229 -0
@@ -1,4 +1,4 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./cssMode-eTXVdAkZ.js","./lspLanguageFeatures-DqzMqkRk.js","./htmlMode-46N3XG2c.js","./jsonMode-DXDczSNu.js","./javascript-n_iZZzDX.js","./typescript-DhPw4VVg.js"])))=>i.map(i=>d[i]);
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./cssMode-BdYp0fp9.js","./lspLanguageFeatures-8SIcRAMh.js","./htmlMode-CCm0aJ6C.js","./jsonMode-BgCUHaFo.js","./javascript-B2niCouI.js","./typescript-rdwAjCi4.js"])))=>i.map(i=>d[i]);
2
2
  function getDefaultExportFromCjs(x) {
3
3
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
4
4
  }
@@ -19163,12 +19163,32 @@ const __vitePreload = function preload(baseModule, deps, importerUrl) {
19163
19163
  };
19164
19164
  const MODEL = "Xenova/whisper-base";
19165
19165
  const TARGET_SAMPLE_RATE = 16e3;
19166
+ const SILENCE_RMS = 8e-3;
19167
+ const LOW_CONFIDENCE_RMS = 0.02;
19168
+ const FILLER_PHRASES = /* @__PURE__ */ new Set([
19169
+ "you",
19170
+ "thank you",
19171
+ "thank you very much",
19172
+ "thank you for watching",
19173
+ "thanks for watching",
19174
+ "please subscribe"
19175
+ ]);
19176
+ function computeRms(pcm) {
19177
+ if (pcm.length === 0) return 0;
19178
+ let sum2 = 0;
19179
+ for (let i2 = 0; i2 < pcm.length; i2++) sum2 += pcm[i2] * pcm[i2];
19180
+ return Math.sqrt(sum2 / pcm.length);
19181
+ }
19182
+ function isFillerOnly(text2) {
19183
+ const norm = text2.toLowerCase().replace(/[.!?,…"']/g, "").replace(/\s+/g, " ").trim();
19184
+ return FILLER_PHRASES.has(norm);
19185
+ }
19166
19186
  let transcriberPromise = null;
19167
19187
  async function loadTranscriber(onProgress) {
19168
19188
  if (!transcriberPromise) {
19169
19189
  transcriberPromise = (async () => {
19170
19190
  const { pipeline, env: env2 } = await __vitePreload(async () => {
19171
- const { pipeline: pipeline2, env: env3 } = await import("./transformers.web-DtSCnG36.js");
19191
+ const { pipeline: pipeline2, env: env3 } = await import("./transformers.web-_PuZoIho.js");
19172
19192
  return { pipeline: pipeline2, env: env3 };
19173
19193
  }, true ? [] : void 0, import.meta.url);
19174
19194
  env2.allowLocalModels = false;
@@ -19212,33 +19232,63 @@ async function blobToPcm16k(blob) {
19212
19232
  const rendered = await offline.startRendering();
19213
19233
  return rendered.getChannelData(0);
19214
19234
  }
19215
- async function transcribeBlob(blob, onProgress) {
19216
- const transcriber = await loadTranscriber(onProgress);
19235
+ async function transcribeBlob(blob, onProgress, onPartial) {
19217
19236
  const pcm = await blobToPcm16k(blob);
19218
19237
  if (!pcm) return "";
19219
- const result = await transcriber(pcm);
19220
- const text2 = Array.isArray(result) ? result.map((r) => r.text).join(" ") : result?.text;
19221
- return (text2 || "").trim();
19222
- }
19223
- function getRecognitionCtor() {
19224
- const w = window;
19225
- return w.SpeechRecognition || w.webkitSpeechRecognition || null;
19238
+ const rms = computeRms(pcm);
19239
+ if (rms < SILENCE_RMS) {
19240
+ console.info(`[voice] near-silent clip (rms=${rms.toFixed(4)}); skipping transcription`);
19241
+ return "";
19242
+ }
19243
+ const transcriber = await loadTranscriber(onProgress);
19244
+ let streamer;
19245
+ if (onPartial) {
19246
+ try {
19247
+ const { WhisperTextStreamer } = await __vitePreload(async () => {
19248
+ const { WhisperTextStreamer: WhisperTextStreamer2 } = await import("./transformers.web-_PuZoIho.js");
19249
+ return { WhisperTextStreamer: WhisperTextStreamer2 };
19250
+ }, true ? [] : void 0, import.meta.url);
19251
+ const tokenizer = transcriber.tokenizer;
19252
+ let committed = "";
19253
+ let current = "";
19254
+ streamer = new WhisperTextStreamer(tokenizer, {
19255
+ on_chunk_start: () => {
19256
+ current = "";
19257
+ },
19258
+ callback_function: (delta) => {
19259
+ current += delta;
19260
+ onPartial((committed + current).replace(/\s+/g, " ").trim());
19261
+ },
19262
+ on_chunk_end: () => {
19263
+ committed = (committed + current).trim() + " ";
19264
+ current = "";
19265
+ }
19266
+ });
19267
+ } catch (err) {
19268
+ console.warn("[voice] streaming preview unavailable", err);
19269
+ }
19270
+ }
19271
+ const result = await transcriber(pcm, {
19272
+ chunk_length_s: 30,
19273
+ stride_length_s: 5,
19274
+ ...streamer ? { streamer } : {}
19275
+ });
19276
+ const text2 = (Array.isArray(result) ? result.map((r) => r.text).join(" ") : result?.text || "").trim();
19277
+ if (text2 && rms < LOW_CONFIDENCE_RMS && isFillerOnly(text2)) {
19278
+ console.info(`[voice] dropping filler-only output "${text2}" from quiet clip (rms=${rms.toFixed(4)})`);
19279
+ return "";
19280
+ }
19281
+ return text2;
19226
19282
  }
19227
- const ENGINE_KEY = "ctlsurf.voiceEngine";
19228
- const WEB_SPEECH_SUPPORTED = getRecognitionCtor() !== null;
19229
- const LOCAL_SUPPORTED = typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined" && typeof OfflineAudioContext !== "undefined";
19230
- const ANY_SUPPORTED = WEB_SPEECH_SUPPORTED || LOCAL_SUPPORTED;
19231
- function loadInitialEngine() {
19232
- if (!WEB_SPEECH_SUPPORTED && LOCAL_SUPPORTED) return "local";
19283
+ const DEVICE_KEY = "ctlsurf.voiceDeviceId";
19284
+ function loadDeviceId() {
19233
19285
  try {
19234
- if (localStorage.getItem(ENGINE_KEY) === "local" && LOCAL_SUPPORTED) return "local";
19286
+ return localStorage.getItem(DEVICE_KEY) || null;
19235
19287
  } catch {
19288
+ return null;
19236
19289
  }
19237
- return WEB_SPEECH_SUPPORTED ? "web-speech" : "local";
19238
- }
19239
- function isEngineUnavailable(code) {
19240
- return code === "network" || code === "service-not-allowed";
19241
19290
  }
19291
+ const LOCAL_SUPPORTED = typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined" && typeof OfflineAudioContext !== "undefined";
19242
19292
  function describeMicError(err) {
19243
19293
  const name = err?.name;
19244
19294
  if (name === "NotAllowedError" || name === "SecurityError") return "Microphone access denied";
@@ -19246,22 +19296,23 @@ function describeMicError(err) {
19246
19296
  return "Could not start microphone";
19247
19297
  }
19248
19298
  function VoiceInput({ onTranscript, variant = "titlebar" }) {
19249
- const [engine, setEngine] = reactExports.useState(loadInitialEngine);
19250
19299
  const [phase, setPhase] = reactExports.useState("idle");
19251
- const [interim, setInterim] = reactExports.useState("");
19252
19300
  const [modelPct, setModelPct] = reactExports.useState(null);
19301
+ const [partial, setPartial] = reactExports.useState("");
19253
19302
  const [error, setError] = reactExports.useState(null);
19254
19303
  const [notice, setNotice] = reactExports.useState(null);
19255
- const recognitionRef = reactExports.useRef(null);
19256
- const finalRef = reactExports.useRef("");
19304
+ const [devices, setDevices] = reactExports.useState([]);
19305
+ const [selectedDeviceId, setSelectedDeviceId] = reactExports.useState(loadDeviceId);
19306
+ const [showDevicePicker, setShowDevicePicker] = reactExports.useState(false);
19307
+ const selectedDeviceIdRef = reactExports.useRef(selectedDeviceId);
19308
+ reactExports.useEffect(() => {
19309
+ selectedDeviceIdRef.current = selectedDeviceId;
19310
+ }, [selectedDeviceId]);
19311
+ const wrapRef = reactExports.useRef(null);
19257
19312
  const streamRef = reactExports.useRef(null);
19258
19313
  const recorderRef = reactExports.useRef(null);
19259
19314
  const chunksRef = reactExports.useRef([]);
19260
19315
  const cancelGestureRef = reactExports.useRef(false);
19261
- const engineRef = reactExports.useRef(engine);
19262
- reactExports.useEffect(() => {
19263
- engineRef.current = engine;
19264
- }, [engine]);
19265
19316
  const onTranscriptRef = reactExports.useRef(onTranscript);
19266
19317
  reactExports.useEffect(() => {
19267
19318
  onTranscriptRef.current = onTranscript;
@@ -19276,72 +19327,83 @@ function VoiceInput({ onTranscript, variant = "titlebar" }) {
19276
19327
  const t = setTimeout(() => setNotice(null), 5e3);
19277
19328
  return () => clearTimeout(t);
19278
19329
  }, [notice]);
19279
- const switchToLocal = reactExports.useCallback((reason) => {
19280
- try {
19281
- localStorage.setItem(ENGINE_KEY, "local");
19282
- } catch {
19283
- }
19284
- setEngine("local");
19285
- setNotice(reason);
19286
- }, []);
19287
19330
  const stopStream = reactExports.useCallback(() => {
19288
19331
  streamRef.current?.getTracks().forEach((t) => t.stop());
19289
19332
  streamRef.current = null;
19290
19333
  }, []);
19291
- const startWebSpeech = reactExports.useCallback(() => {
19292
- const Ctor = getRecognitionCtor();
19293
- if (!Ctor || recognitionRef.current) return;
19294
- setError(null);
19295
- setNotice(null);
19296
- setInterim("");
19297
- finalRef.current = "";
19298
- const rec = new Ctor();
19299
- rec.lang = navigator.language || "en-US";
19300
- rec.continuous = true;
19301
- rec.interimResults = true;
19302
- rec.onresult = (event) => {
19303
- let finalText = "";
19304
- let interimText = "";
19305
- for (let i2 = 0; i2 < event.results.length; i2++) {
19306
- const res = event.results[i2];
19307
- if (res.isFinal) finalText += res[0].transcript;
19308
- else interimText += res[0].transcript;
19309
- }
19310
- finalRef.current = finalText;
19311
- setInterim(interimText);
19312
- };
19313
- rec.onerror = (event) => {
19314
- if (isEngineUnavailable(event.error) && LOCAL_SUPPORTED) {
19315
- finalRef.current = "";
19316
- switchToLocal("Voice service unavailable — switched to on-device. Press again.");
19317
- } else if (event.error !== "no-speech" && event.error !== "aborted") {
19318
- setError(event.error === "not-allowed" ? "Microphone access denied" : `Voice error: ${event.error}`);
19319
- }
19320
- };
19321
- rec.onend = () => {
19322
- const text2 = finalRef.current.trim();
19323
- recognitionRef.current = null;
19324
- setPhase("idle");
19325
- setInterim("");
19326
- if (text2) onTranscriptRef.current(text2);
19327
- };
19328
- recognitionRef.current = rec;
19334
+ const refreshDevices = reactExports.useCallback(async () => {
19335
+ if (!navigator.mediaDevices?.enumerateDevices) return;
19329
19336
  try {
19330
- rec.start();
19331
- setPhase("listening");
19332
- } catch (err) {
19333
- recognitionRef.current = null;
19334
- setPhase("idle");
19335
- setError("Could not start microphone");
19336
- console.error("[voice] web speech start failed", err);
19337
+ const all = await navigator.mediaDevices.enumerateDevices();
19338
+ setDevices(all.filter((d) => d.kind === "audioinput"));
19339
+ } catch {
19337
19340
  }
19338
- }, [switchToLocal]);
19339
- const stopWebSpeech = reactExports.useCallback(() => {
19341
+ }, []);
19342
+ const ensureDeviceLabels = reactExports.useCallback(async () => {
19343
+ if (!navigator.mediaDevices?.enumerateDevices) return;
19340
19344
  try {
19341
- recognitionRef.current?.stop();
19345
+ const all = await navigator.mediaDevices.enumerateDevices();
19346
+ const inputs = all.filter((d) => d.kind === "audioinput");
19347
+ if (inputs.length && inputs.every((d) => !d.label)) {
19348
+ const s = await navigator.mediaDevices.getUserMedia({ audio: true });
19349
+ s.getTracks().forEach((t) => t.stop());
19350
+ }
19342
19351
  } catch {
19343
19352
  }
19353
+ await refreshDevices();
19354
+ }, [refreshDevices]);
19355
+ const toggleDevicePicker = reactExports.useCallback(() => {
19356
+ setShowDevicePicker((open) => !open);
19357
+ }, []);
19358
+ reactExports.useEffect(() => {
19359
+ if (showDevicePicker) void ensureDeviceLabels();
19360
+ }, [showDevicePicker, ensureDeviceLabels]);
19361
+ const chooseDevice = reactExports.useCallback((id) => {
19362
+ setSelectedDeviceId(id);
19363
+ try {
19364
+ if (id) localStorage.setItem(DEVICE_KEY, id);
19365
+ else localStorage.removeItem(DEVICE_KEY);
19366
+ } catch {
19367
+ }
19368
+ setShowDevicePicker(false);
19369
+ }, []);
19370
+ const getStream = reactExports.useCallback(async () => {
19371
+ const id = selectedDeviceIdRef.current;
19372
+ try {
19373
+ return await navigator.mediaDevices.getUserMedia({
19374
+ audio: id ? { deviceId: { exact: id } } : true
19375
+ });
19376
+ } catch (err) {
19377
+ if (id && err?.name === "OverconstrainedError") {
19378
+ try {
19379
+ localStorage.removeItem(DEVICE_KEY);
19380
+ } catch {
19381
+ }
19382
+ setSelectedDeviceId(null);
19383
+ setNotice("Saved microphone unavailable — using system default.");
19384
+ return navigator.mediaDevices.getUserMedia({ audio: true });
19385
+ }
19386
+ throw err;
19387
+ }
19344
19388
  }, []);
19389
+ reactExports.useEffect(() => {
19390
+ const md = navigator.mediaDevices;
19391
+ if (!md?.addEventListener) return;
19392
+ const onChange = () => {
19393
+ void refreshDevices();
19394
+ };
19395
+ md.addEventListener("devicechange", onChange);
19396
+ void refreshDevices();
19397
+ return () => md.removeEventListener("devicechange", onChange);
19398
+ }, [refreshDevices]);
19399
+ reactExports.useEffect(() => {
19400
+ if (!showDevicePicker) return;
19401
+ const onDocDown = (e) => {
19402
+ if (!wrapRef.current?.contains(e.target)) setShowDevicePicker(false);
19403
+ };
19404
+ document.addEventListener("mousedown", onDocDown);
19405
+ return () => document.removeEventListener("mousedown", onDocDown);
19406
+ }, [showDevicePicker]);
19345
19407
  const handleModelProgress = reactExports.useCallback((p) => {
19346
19408
  if (p.status === "progress" && typeof p.progress === "number") {
19347
19409
  setModelPct(Math.min(100, Math.round(p.progress)));
@@ -19357,25 +19419,29 @@ function VoiceInput({ onTranscript, variant = "titlebar" }) {
19357
19419
  return;
19358
19420
  }
19359
19421
  setPhase("transcribing");
19360
- setInterim("");
19422
+ setPartial("");
19361
19423
  try {
19362
- const text2 = await transcribeBlob(blob, handleModelProgress);
19424
+ const text2 = await transcribeBlob(blob, handleModelProgress, (t) => {
19425
+ setModelPct(null);
19426
+ setPartial(t);
19427
+ });
19363
19428
  if (text2) onTranscriptRef.current(text2);
19429
+ else setNotice("No speech detected — check the mic source (▾).");
19364
19430
  } catch (err) {
19365
19431
  setError("On-device transcription failed");
19366
19432
  console.error("[voice] local transcription failed", err);
19367
19433
  } finally {
19368
19434
  setPhase("idle");
19369
19435
  setModelPct(null);
19436
+ setPartial("");
19370
19437
  }
19371
19438
  }, [stopStream, handleModelProgress]);
19372
19439
  const startLocal = reactExports.useCallback(async () => {
19373
19440
  setError(null);
19374
19441
  setNotice(null);
19375
- setInterim("");
19376
19442
  cancelGestureRef.current = false;
19377
19443
  try {
19378
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
19444
+ const stream = await getStream();
19379
19445
  if (cancelGestureRef.current) {
19380
19446
  stream.getTracks().forEach((t) => t.stop());
19381
19447
  setPhase("idle");
@@ -19399,7 +19465,7 @@ function VoiceInput({ onTranscript, variant = "titlebar" }) {
19399
19465
  setError(describeMicError(err));
19400
19466
  console.error("[voice] getUserMedia failed", err);
19401
19467
  }
19402
- }, [runLocalTranscription, stopStream]);
19468
+ }, [runLocalTranscription, stopStream, getStream]);
19403
19469
  const stopLocal = reactExports.useCallback(() => {
19404
19470
  cancelGestureRef.current = true;
19405
19471
  const rec = recorderRef.current;
@@ -19411,22 +19477,16 @@ function VoiceInput({ onTranscript, variant = "titlebar" }) {
19411
19477
  }
19412
19478
  }, []);
19413
19479
  const handlePointerDown = (e) => {
19414
- if (!ANY_SUPPORTED || phase !== "idle") return;
19480
+ if (!LOCAL_SUPPORTED || phase !== "idle") return;
19415
19481
  e.preventDefault();
19416
19482
  e.currentTarget.setPointerCapture?.(e.pointerId);
19417
- if (engineRef.current === "web-speech" && WEB_SPEECH_SUPPORTED) startWebSpeech();
19418
- else if (LOCAL_SUPPORTED) void startLocal();
19483
+ void startLocal();
19419
19484
  };
19420
19485
  const handlePointerUp = (e) => {
19421
19486
  e.currentTarget.releasePointerCapture?.(e.pointerId);
19422
- if (engineRef.current === "web-speech") stopWebSpeech();
19423
- else stopLocal();
19487
+ stopLocal();
19424
19488
  };
19425
19489
  reactExports.useEffect(() => () => {
19426
- try {
19427
- recognitionRef.current?.abort();
19428
- } catch {
19429
- }
19430
19490
  try {
19431
19491
  recorderRef.current?.stop();
19432
19492
  } catch {
@@ -19435,21 +19495,34 @@ function VoiceInput({ onTranscript, variant = "titlebar" }) {
19435
19495
  }, []);
19436
19496
  const listening = phase === "listening";
19437
19497
  const busy = phase === "transcribing";
19438
- const title = !ANY_SUPPORTED ? "Voice typing not supported in this build" : listening ? "Listening… release to insert" : busy ? "Transcribing…" : engine === "local" ? "Hold to talk (on-device) — speech is typed into the terminal" : "Hold to talk — speech is typed into the terminal";
19498
+ const title = !LOCAL_SUPPORTED ? "Voice typing not supported in this build" : listening ? "Listening… release to insert" : busy ? "Transcribing…" : "Hold to talk (on-device) — speech is typed into the terminal";
19439
19499
  let chip = null;
19440
19500
  if (error && phase === "idle") chip = { kind: "error", text: error };
19441
19501
  else if (notice && phase === "idle") chip = { kind: "notice", text: notice };
19442
- else if (listening) chip = { kind: "listening", text: interim || (engine === "local" ? "Recording…" : "Listening…") };
19443
- else if (busy) chip = { kind: "busy", text: modelPct !== null ? `Downloading voice model… ${modelPct}%` : "Transcribing…" };
19502
+ else if (listening) chip = { kind: "listening", text: "Recording…" };
19503
+ else if (busy) {
19504
+ const preview = partial.length > 90 ? "…" + partial.slice(-90) : partial;
19505
+ chip = {
19506
+ kind: "busy",
19507
+ text: modelPct !== null ? `Downloading voice model… ${modelPct}%` : preview || "Transcribing…"
19508
+ };
19509
+ }
19444
19510
  const floating = variant === "floating";
19445
19511
  const btnClass = floating ? `voice-btn voice-btn-floating ${listening ? "listening" : ""} ${busy ? "busy" : ""}` : `titlebar-btn titlebar-icon-btn voice-btn ${listening ? "listening" : ""} ${busy ? "busy" : ""}`;
19446
- return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "voice-input-wrap", children: [
19512
+ const realDevices = devices.filter(
19513
+ (d) => d.deviceId && d.deviceId !== "default" && d.deviceId !== "communications"
19514
+ );
19515
+ const rawDefault = devices.find((d) => d.deviceId === "default")?.label;
19516
+ const defaultLabel = rawDefault ? `System default · ${rawDefault.replace(/^Default\s*-\s*/i, "")}` : "System default";
19517
+ const activeLabel = selectedDeviceId ? realDevices.find((d) => d.deviceId === selectedDeviceId)?.label || "Selected microphone" : defaultLabel;
19518
+ const showSourcePicker = floating && LOCAL_SUPPORTED;
19519
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "voice-input-wrap", ref: wrapRef, children: [
19447
19520
  /* @__PURE__ */ jsxRuntimeExports.jsxs(
19448
19521
  "button",
19449
19522
  {
19450
19523
  type: "button",
19451
19524
  className: btnClass,
19452
- disabled: !ANY_SUPPORTED,
19525
+ disabled: !LOCAL_SUPPORTED,
19453
19526
  onPointerDown: handlePointerDown,
19454
19527
  onPointerUp: handlePointerUp,
19455
19528
  onPointerCancel: handlePointerUp,
@@ -19462,9 +19535,336 @@ function VoiceInput({ onTranscript, variant = "titlebar" }) {
19462
19535
  ]
19463
19536
  }
19464
19537
  ),
19538
+ showSourcePicker && /* @__PURE__ */ jsxRuntimeExports.jsx(
19539
+ "button",
19540
+ {
19541
+ type: "button",
19542
+ className: "voice-source-btn",
19543
+ onPointerDown: (e) => e.stopPropagation(),
19544
+ onClick: toggleDevicePicker,
19545
+ title: `Mic source: ${activeLabel}`,
19546
+ "aria-label": "Choose microphone source",
19547
+ "aria-expanded": showDevicePicker,
19548
+ children: "▾"
19549
+ }
19550
+ ),
19551
+ showSourcePicker && showDevicePicker && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "voice-source-menu", role: "menu", children: [
19552
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "voice-source-head", children: "Microphone source" }),
19553
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
19554
+ "button",
19555
+ {
19556
+ type: "button",
19557
+ role: "menuitemradio",
19558
+ "aria-checked": selectedDeviceId === null,
19559
+ className: `voice-source-item ${selectedDeviceId === null ? "active" : ""}`,
19560
+ onClick: () => chooseDevice(null),
19561
+ children: [
19562
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "voice-source-check", children: selectedDeviceId === null ? "✓" : "" }),
19563
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "voice-source-label", children: defaultLabel })
19564
+ ]
19565
+ }
19566
+ ),
19567
+ realDevices.map((d, i2) => /* @__PURE__ */ jsxRuntimeExports.jsxs(
19568
+ "button",
19569
+ {
19570
+ type: "button",
19571
+ role: "menuitemradio",
19572
+ "aria-checked": selectedDeviceId === d.deviceId,
19573
+ className: `voice-source-item ${selectedDeviceId === d.deviceId ? "active" : ""}`,
19574
+ onClick: () => chooseDevice(d.deviceId),
19575
+ children: [
19576
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "voice-source-check", children: selectedDeviceId === d.deviceId ? "✓" : "" }),
19577
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "voice-source-label", children: d.label || `Microphone ${i2 + 1}` })
19578
+ ]
19579
+ },
19580
+ d.deviceId
19581
+ )),
19582
+ realDevices.length === 0 && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "voice-source-empty", children: "No microphones found" })
19583
+ ] }),
19465
19584
  chip && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: `voice-chip ${chip.kind} ${floating ? "voice-chip-floating" : ""}`, children: chip.text })
19466
19585
  ] });
19467
19586
  }
19587
+ const ENGINE_KEY = "ctlsurf.tts.engine";
19588
+ const VOICE_KEY = "ctlsurf.tts.voiceURI";
19589
+ const RATE_KEY = "ctlsurf.tts.rate";
19590
+ const MAX_SPEAK_CHARS = 1600;
19591
+ const MAX_CHUNK_CHARS = 280;
19592
+ function cleanForSpeech(input) {
19593
+ let t = input;
19594
+ t = t.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_m, body) => {
19595
+ const lines = body.replace(/\n+$/, "").split("\n").filter((l2) => l2.trim().length).length;
19596
+ return lines > 0 ? ` (code block, ${lines} ${lines === 1 ? "line" : "lines"}) ` : " (code) ";
19597
+ });
19598
+ t = t.replace(/```/g, " (code) ");
19599
+ t = t.replace(/`([^`]+)`/g, "$1");
19600
+ t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
19601
+ t = t.replace(/https?:\/\/\S+/g, "link");
19602
+ t = t.replace(/(?:[\w.-]*\/){2,}([\w.-]+)/g, "$1");
19603
+ t = t.replace(/[*_#>]+/g, " ");
19604
+ t = t.replace(/^\s*[-•]\s+/gm, ", ");
19605
+ t = t.replace(/<[^>]+>/g, " ");
19606
+ t = t.replace(/\s+/g, " ").trim();
19607
+ if (t.length > MAX_SPEAK_CHARS) {
19608
+ t = t.slice(0, MAX_SPEAK_CHARS).replace(/\s+\S*$/, "") + "…";
19609
+ }
19610
+ return t;
19611
+ }
19612
+ function splitIntoChunks(text2) {
19613
+ if (text2.length <= MAX_CHUNK_CHARS) return text2 ? [text2] : [];
19614
+ const sentences = text2.match(/[^.!?]+[.!?]+|\S[^.!?]*$/g) || [text2];
19615
+ const chunks = [];
19616
+ let buf = "";
19617
+ for (const s of sentences) {
19618
+ if ((buf + s).length > MAX_CHUNK_CHARS && buf) {
19619
+ chunks.push(buf.trim());
19620
+ buf = "";
19621
+ }
19622
+ if (s.length > MAX_CHUNK_CHARS) {
19623
+ for (let i2 = 0; i2 < s.length; i2 += MAX_CHUNK_CHARS) chunks.push(s.slice(i2, i2 + MAX_CHUNK_CHARS).trim());
19624
+ } else {
19625
+ buf += s;
19626
+ }
19627
+ }
19628
+ if (buf.trim()) chunks.push(buf.trim());
19629
+ return chunks;
19630
+ }
19631
+ function getEngine() {
19632
+ try {
19633
+ return localStorage.getItem(ENGINE_KEY) === "neural" ? "neural" : "web";
19634
+ } catch {
19635
+ return "web";
19636
+ }
19637
+ }
19638
+ function setEngine(id) {
19639
+ try {
19640
+ localStorage.setItem(ENGINE_KEY, id);
19641
+ } catch {
19642
+ }
19643
+ }
19644
+ function getVoiceURI() {
19645
+ try {
19646
+ return localStorage.getItem(VOICE_KEY) || null;
19647
+ } catch {
19648
+ return null;
19649
+ }
19650
+ }
19651
+ function setVoiceURI(uri) {
19652
+ try {
19653
+ uri ? localStorage.setItem(VOICE_KEY, uri) : localStorage.removeItem(VOICE_KEY);
19654
+ } catch {
19655
+ }
19656
+ }
19657
+ function getRate() {
19658
+ try {
19659
+ const n2 = Number(localStorage.getItem(RATE_KEY));
19660
+ return Number.isFinite(n2) && n2 >= 0.5 && n2 <= 2 ? n2 : 1;
19661
+ } catch {
19662
+ return 1;
19663
+ }
19664
+ }
19665
+ function setRate(rate) {
19666
+ try {
19667
+ localStorage.setItem(RATE_KEY, String(rate));
19668
+ } catch {
19669
+ }
19670
+ }
19671
+ function listWebVoices() {
19672
+ if (typeof speechSynthesis === "undefined") return [];
19673
+ return speechSynthesis.getVoices();
19674
+ }
19675
+ const NEURAL_MODEL = "Xenova/mms-tts-eng";
19676
+ let synthPromise = null;
19677
+ function loadSynthesizer(onProgress) {
19678
+ if (!synthPromise) {
19679
+ synthPromise = (async () => {
19680
+ const { pipeline, env: env2 } = await __vitePreload(async () => {
19681
+ const { pipeline: pipeline2, env: env3 } = await import("./transformers.web-_PuZoIho.js");
19682
+ return { pipeline: pipeline2, env: env3 };
19683
+ }, true ? [] : void 0, import.meta.url);
19684
+ env2.allowLocalModels = false;
19685
+ const common = { progress_callback: onProgress };
19686
+ const hasWebGpu = typeof navigator !== "undefined" && "gpu" in navigator;
19687
+ if (hasWebGpu) {
19688
+ try {
19689
+ const s2 = await pipeline("text-to-speech", NEURAL_MODEL, { ...common, device: "webgpu" });
19690
+ console.info("[tts] neural backend: webgpu");
19691
+ return s2;
19692
+ } catch (err) {
19693
+ console.warn("[tts] WebGPU backend failed, falling back to WASM", err);
19694
+ }
19695
+ }
19696
+ const s = await pipeline("text-to-speech", NEURAL_MODEL, common);
19697
+ console.info("[tts] neural backend: wasm");
19698
+ return s;
19699
+ })();
19700
+ synthPromise.catch(() => {
19701
+ synthPromise = null;
19702
+ });
19703
+ }
19704
+ return synthPromise;
19705
+ }
19706
+ class SpeechController {
19707
+ queue = [];
19708
+ draining = false;
19709
+ audioCtx = null;
19710
+ currentSource = null;
19711
+ generation = 0;
19712
+ // bumped by stop() to abort in-flight work
19713
+ active = false;
19714
+ onModelProgress = null;
19715
+ // Fires true while a reply is being spoken/queued, false when idle — drives
19716
+ // the visible Stop button.
19717
+ onActivityChange = null;
19718
+ onError = null;
19719
+ // Multiple UI surfaces (titlebar SpeakControls + floating read button) need
19720
+ // the speaking state at once, so beyond the single onActivityChange slot we
19721
+ // also keep a listener set.
19722
+ activityListeners = /* @__PURE__ */ new Set();
19723
+ get isActive() {
19724
+ return this.active;
19725
+ }
19726
+ // Subscribe to speaking-state changes. Fires immediately with the current
19727
+ // state so a late subscriber syncs. Returns an unsubscribe function.
19728
+ onActivity(fn) {
19729
+ this.activityListeners.add(fn);
19730
+ fn(this.active);
19731
+ return () => {
19732
+ this.activityListeners.delete(fn);
19733
+ };
19734
+ }
19735
+ // Prime/resume the AudioContext from a user gesture so neural playback isn't
19736
+ // blocked by the browser autoplay policy (the System engine is unaffected).
19737
+ unlock() {
19738
+ try {
19739
+ const ctx = this.ensureCtx();
19740
+ if (ctx.state === "suspended") void ctx.resume();
19741
+ } catch {
19742
+ }
19743
+ }
19744
+ // Start loading the neural model in the background (e.g. when the user picks
19745
+ // the neural engine) so the first utterance doesn't pay download/compile time.
19746
+ warmup() {
19747
+ if (getEngine() !== "neural") return;
19748
+ void loadSynthesizer((p) => this.onModelProgress?.(p)).then(() => this.onModelProgress?.(null)).catch(() => {
19749
+ this.onModelProgress?.(null);
19750
+ });
19751
+ }
19752
+ enqueue(rawText) {
19753
+ const text2 = cleanForSpeech(rawText);
19754
+ if (!text2) return;
19755
+ this.queue.push(...splitIntoChunks(text2));
19756
+ void this.drain();
19757
+ }
19758
+ stop() {
19759
+ this.generation++;
19760
+ this.queue = [];
19761
+ this.draining = false;
19762
+ if (typeof speechSynthesis !== "undefined") {
19763
+ try {
19764
+ speechSynthesis.cancel();
19765
+ } catch {
19766
+ }
19767
+ }
19768
+ if (this.currentSource) {
19769
+ try {
19770
+ this.currentSource.stop();
19771
+ } catch {
19772
+ }
19773
+ this.currentSource = null;
19774
+ }
19775
+ this.onModelProgress?.(null);
19776
+ this.setActive(false);
19777
+ }
19778
+ ensureCtx() {
19779
+ if (!this.audioCtx) {
19780
+ const Ctx = window.AudioContext || window.webkitAudioContext;
19781
+ this.audioCtx = new Ctx();
19782
+ }
19783
+ return this.audioCtx;
19784
+ }
19785
+ setActive(a) {
19786
+ if (this.active === a) return;
19787
+ this.active = a;
19788
+ this.onActivityChange?.(a);
19789
+ this.activityListeners.forEach((fn) => fn(a));
19790
+ }
19791
+ async drain() {
19792
+ if (this.draining) return;
19793
+ this.draining = true;
19794
+ const gen = this.generation;
19795
+ this.setActive(true);
19796
+ while (this.queue.length && gen === this.generation) {
19797
+ const chunk = this.queue.shift();
19798
+ try {
19799
+ if (getEngine() === "neural") await this.speakNeural(chunk, gen);
19800
+ else await this.speakWeb(chunk, gen);
19801
+ } catch (err) {
19802
+ console.error("[tts] speak failed", err);
19803
+ this.onModelProgress?.(null);
19804
+ const detail = err instanceof Error ? err.message : String(err);
19805
+ this.onError?.(getEngine() === "neural" ? `Neural voice failed: ${detail}` : "Speech failed");
19806
+ }
19807
+ }
19808
+ if (gen === this.generation) {
19809
+ this.draining = false;
19810
+ this.setActive(false);
19811
+ }
19812
+ }
19813
+ speakWeb(text2, gen) {
19814
+ return new Promise((resolve2) => {
19815
+ if (typeof speechSynthesis === "undefined" || gen !== this.generation) return resolve2();
19816
+ const u = new SpeechSynthesisUtterance(text2);
19817
+ u.rate = getRate();
19818
+ const wantUri = getVoiceURI();
19819
+ if (wantUri) {
19820
+ const v2 = speechSynthesis.getVoices().find((vv) => vv.voiceURI === wantUri);
19821
+ if (v2) u.voice = v2;
19822
+ }
19823
+ u.onend = () => resolve2();
19824
+ u.onerror = () => resolve2();
19825
+ speechSynthesis.speak(u);
19826
+ });
19827
+ }
19828
+ async speakNeural(text2, gen) {
19829
+ console.info("[tts] neural: loading model…");
19830
+ this.onModelProgress?.({ status: "loading" });
19831
+ const synth = await loadSynthesizer((p) => this.onModelProgress?.(p));
19832
+ if (gen !== this.generation) return;
19833
+ console.info("[tts] neural: synthesizing", JSON.stringify(text2.slice(0, 60)));
19834
+ const out = await synth(text2);
19835
+ const raw = Array.isArray(out) ? out[0] : out;
19836
+ this.onModelProgress?.(null);
19837
+ if (gen !== this.generation) return;
19838
+ if (!raw?.audio?.length) throw new Error("neural synth returned no audio");
19839
+ console.info(`[tts] neural: playing ${raw.audio.length} samples @ ${raw.sampling_rate}Hz`);
19840
+ await this.playPcm(raw.audio, raw.sampling_rate, gen);
19841
+ }
19842
+ async playPcm(pcm, sampleRate, gen) {
19843
+ if (gen !== this.generation) return;
19844
+ const ctx = this.ensureCtx();
19845
+ if (ctx.state === "suspended") {
19846
+ try {
19847
+ await ctx.resume();
19848
+ } catch {
19849
+ }
19850
+ }
19851
+ if (gen !== this.generation) return;
19852
+ return new Promise((resolve2) => {
19853
+ const buffer = ctx.createBuffer(1, pcm.length, sampleRate);
19854
+ buffer.getChannelData(0).set(pcm);
19855
+ const source = ctx.createBufferSource();
19856
+ source.buffer = buffer;
19857
+ source.connect(ctx.destination);
19858
+ source.onended = () => {
19859
+ if (this.currentSource === source) this.currentSource = null;
19860
+ resolve2();
19861
+ };
19862
+ this.currentSource = source;
19863
+ source.start();
19864
+ });
19865
+ }
19866
+ }
19867
+ const speech = new SpeechController();
19468
19868
  const POS_KEY = "ctlsurf.floatingMicPos";
19469
19869
  const EDGE = 20;
19470
19870
  const TOP_MIN = 46;
@@ -19484,6 +19884,28 @@ function FloatingMic({ onTranscript, onHide }) {
19484
19884
  const [pos, setPos] = reactExports.useState(loadPos);
19485
19885
  const elRef = reactExports.useRef(null);
19486
19886
  const dragRef = reactExports.useRef(null);
19887
+ const [speaking, setSpeaking] = reactExports.useState(false);
19888
+ const [note, setNote] = reactExports.useState(null);
19889
+ reactExports.useEffect(() => speech.onActivity(setSpeaking), []);
19890
+ reactExports.useEffect(() => {
19891
+ if (!note) return;
19892
+ const t = setTimeout(() => setNote(null), 3e3);
19893
+ return () => clearTimeout(t);
19894
+ }, [note]);
19895
+ const onReadAloud = reactExports.useCallback(async () => {
19896
+ if (speaking) {
19897
+ speech.stop();
19898
+ return;
19899
+ }
19900
+ speech.unlock();
19901
+ let text2 = null;
19902
+ try {
19903
+ text2 = await window.worker.getLastReply();
19904
+ } catch {
19905
+ }
19906
+ if (text2) speech.enqueue(text2);
19907
+ else setNote("No reply to read yet");
19908
+ }, [speaking]);
19487
19909
  const clamp2 = reactExports.useCallback((x, y) => {
19488
19910
  const el = elRef.current;
19489
19911
  const w = el?.offsetWidth ?? 64;
@@ -19564,7 +19986,265 @@ function FloatingMic({ onTranscript, onHide }) {
19564
19986
  ]
19565
19987
  }
19566
19988
  ),
19567
- /* @__PURE__ */ jsxRuntimeExports.jsx(VoiceInput, { variant: "floating", onTranscript })
19989
+ /* @__PURE__ */ jsxRuntimeExports.jsx(VoiceInput, { variant: "floating", onTranscript }),
19990
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
19991
+ "button",
19992
+ {
19993
+ type: "button",
19994
+ className: `floating-mic-read ${speaking ? "speaking" : ""}`,
19995
+ onPointerDown: (e) => e.stopPropagation(),
19996
+ onClick: onReadAloud,
19997
+ title: speaking ? "Stop reading" : "Read last agent reply aloud",
19998
+ "aria-label": speaking ? "Stop reading" : "Read last agent reply aloud",
19999
+ "aria-pressed": speaking,
20000
+ children: speaking ? /* @__PURE__ */ jsxRuntimeExports.jsx("svg", { viewBox: "0 0 24 24", width: "14", height: "14", fill: "currentColor", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntimeExports.jsx("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2" }) }) : /* @__PURE__ */ jsxRuntimeExports.jsxs(
20001
+ "svg",
20002
+ {
20003
+ viewBox: "0 0 24 24",
20004
+ width: "15",
20005
+ height: "15",
20006
+ fill: "none",
20007
+ stroke: "currentColor",
20008
+ strokeWidth: "2",
20009
+ strokeLinecap: "round",
20010
+ strokeLinejoin: "round",
20011
+ "aria-hidden": "true",
20012
+ children: [
20013
+ /* @__PURE__ */ jsxRuntimeExports.jsx("polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5" }),
20014
+ /* @__PURE__ */ jsxRuntimeExports.jsx("path", { d: "M15.54 8.46a5 5 0 0 1 0 7.07" }),
20015
+ /* @__PURE__ */ jsxRuntimeExports.jsx("path", { d: "M19.07 4.93a10 10 0 0 1 0 14.14" })
20016
+ ]
20017
+ }
20018
+ )
20019
+ }
20020
+ ),
20021
+ note && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "floating-mic-note", children: note })
20022
+ ] });
20023
+ }
20024
+ const SAMPLE = "This is how spoken agent replies will sound.";
20025
+ function SpeakControls() {
20026
+ const [enabled, setEnabled] = reactExports.useState(false);
20027
+ const [engine, setEngineState] = reactExports.useState(getEngine);
20028
+ const [voices, setVoices] = reactExports.useState([]);
20029
+ const [voiceURI, setVoiceURIState] = reactExports.useState(getVoiceURI);
20030
+ const [rate, setRateState] = reactExports.useState(getRate);
20031
+ const [showMenu, setShowMenu] = reactExports.useState(false);
20032
+ const [modelPct, setModelPct] = reactExports.useState(null);
20033
+ const [speaking, setSpeaking] = reactExports.useState(false);
20034
+ const [error, setError] = reactExports.useState(null);
20035
+ const wrapRef = reactExports.useRef(null);
20036
+ reactExports.useEffect(() => {
20037
+ let alive = true;
20038
+ window.worker.getSpeakReplies().then((r) => {
20039
+ if (alive) setEnabled(!!r.enabled);
20040
+ }).catch(() => {
20041
+ });
20042
+ return () => {
20043
+ alive = false;
20044
+ };
20045
+ }, []);
20046
+ reactExports.useEffect(() => {
20047
+ const off = window.worker.onAgentMessage((text2) => speech.enqueue(text2));
20048
+ return off;
20049
+ }, []);
20050
+ reactExports.useEffect(() => {
20051
+ speech.onModelProgress = (p) => {
20052
+ if (p && p.status === "progress" && typeof p.progress === "number") {
20053
+ setModelPct(Math.min(100, Math.round(p.progress)));
20054
+ } else if (!p) {
20055
+ setModelPct(null);
20056
+ }
20057
+ };
20058
+ speech.onActivityChange = (a) => setSpeaking(a);
20059
+ speech.onError = (msg) => setError(msg);
20060
+ return () => {
20061
+ speech.onModelProgress = null;
20062
+ speech.onActivityChange = null;
20063
+ speech.onError = null;
20064
+ };
20065
+ }, []);
20066
+ reactExports.useEffect(() => {
20067
+ if (!error) return;
20068
+ const t = setTimeout(() => setError(null), 4e3);
20069
+ return () => clearTimeout(t);
20070
+ }, [error]);
20071
+ reactExports.useEffect(() => {
20072
+ const load = () => setVoices(listWebVoices());
20073
+ load();
20074
+ if (typeof speechSynthesis !== "undefined") {
20075
+ speechSynthesis.addEventListener("voiceschanged", load);
20076
+ return () => speechSynthesis.removeEventListener("voiceschanged", load);
20077
+ }
20078
+ }, []);
20079
+ reactExports.useEffect(() => {
20080
+ if (!showMenu) return;
20081
+ const onDown = (e) => {
20082
+ if (!wrapRef.current?.contains(e.target)) setShowMenu(false);
20083
+ };
20084
+ document.addEventListener("mousedown", onDown);
20085
+ return () => document.removeEventListener("mousedown", onDown);
20086
+ }, [showMenu]);
20087
+ const toggle = reactExports.useCallback(async () => {
20088
+ const next = !enabled;
20089
+ setEnabled(next);
20090
+ if (next) speech.unlock();
20091
+ else speech.stop();
20092
+ try {
20093
+ await window.worker.setSpeakReplies(next);
20094
+ } catch {
20095
+ }
20096
+ }, [enabled]);
20097
+ const chooseEngine = reactExports.useCallback((id) => {
20098
+ setEngine(id);
20099
+ setEngineState(id);
20100
+ speech.stop();
20101
+ if (id === "neural") speech.warmup();
20102
+ }, []);
20103
+ const chooseVoice = reactExports.useCallback((uri) => {
20104
+ const v2 = uri || null;
20105
+ setVoiceURI(v2);
20106
+ setVoiceURIState(v2);
20107
+ }, []);
20108
+ const changeRate = reactExports.useCallback((r) => {
20109
+ setRate(r);
20110
+ setRateState(r);
20111
+ }, []);
20112
+ const testVoice = reactExports.useCallback(() => {
20113
+ speech.unlock();
20114
+ speech.stop();
20115
+ speech.enqueue(SAMPLE);
20116
+ }, []);
20117
+ const title = enabled ? "Spoken replies on — click to mute" : "Speak agent replies (off)";
20118
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "speak-controls", ref: wrapRef, children: [
20119
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
20120
+ "button",
20121
+ {
20122
+ type: "button",
20123
+ className: `titlebar-btn titlebar-icon-btn speak-btn ${enabled ? "active" : ""}`,
20124
+ onClick: toggle,
20125
+ title,
20126
+ "aria-label": "Toggle spoken agent replies",
20127
+ "aria-pressed": enabled,
20128
+ children: [
20129
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
20130
+ "svg",
20131
+ {
20132
+ viewBox: "0 0 24 24",
20133
+ width: "13",
20134
+ height: "13",
20135
+ fill: "none",
20136
+ stroke: "currentColor",
20137
+ strokeWidth: "2",
20138
+ strokeLinecap: "round",
20139
+ strokeLinejoin: "round",
20140
+ "aria-hidden": "true",
20141
+ children: [
20142
+ /* @__PURE__ */ jsxRuntimeExports.jsx("polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5" }),
20143
+ enabled ? /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
20144
+ /* @__PURE__ */ jsxRuntimeExports.jsx("path", { d: "M15.54 8.46a5 5 0 0 1 0 7.07" }),
20145
+ /* @__PURE__ */ jsxRuntimeExports.jsx("path", { d: "M19.07 4.93a10 10 0 0 1 0 14.14" })
20146
+ ] }) : /* @__PURE__ */ jsxRuntimeExports.jsx("line", { x1: "23", y1: "9", x2: "17", y2: "15" })
20147
+ ]
20148
+ }
20149
+ ),
20150
+ modelPct !== null && /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "speak-pct", children: [
20151
+ modelPct,
20152
+ "%"
20153
+ ] })
20154
+ ]
20155
+ }
20156
+ ),
20157
+ speaking && /* @__PURE__ */ jsxRuntimeExports.jsx(
20158
+ "button",
20159
+ {
20160
+ type: "button",
20161
+ className: "titlebar-btn titlebar-icon-btn speak-stop",
20162
+ onClick: () => speech.stop(),
20163
+ title: "Stop speaking",
20164
+ "aria-label": "Stop speaking",
20165
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx("svg", { viewBox: "0 0 24 24", width: "11", height: "11", fill: "currentColor", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntimeExports.jsx("rect", { x: "5", y: "5", width: "14", height: "14", rx: "2" }) })
20166
+ }
20167
+ ),
20168
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
20169
+ "button",
20170
+ {
20171
+ type: "button",
20172
+ className: "titlebar-btn speak-caret",
20173
+ onClick: () => setShowMenu((v2) => !v2),
20174
+ title: "Voice options",
20175
+ "aria-label": "Voice options",
20176
+ "aria-expanded": showMenu,
20177
+ children: "▾"
20178
+ }
20179
+ ),
20180
+ showMenu && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "speak-menu", role: "menu", children: [
20181
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "speak-menu-head", children: "Engine" }),
20182
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
20183
+ "button",
20184
+ {
20185
+ type: "button",
20186
+ className: `speak-menu-item ${engine === "web" ? "active" : ""}`,
20187
+ onClick: () => chooseEngine("web"),
20188
+ children: [
20189
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "speak-menu-check", children: engine === "web" ? "✓" : "" }),
20190
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "System voice (instant)" })
20191
+ ]
20192
+ }
20193
+ ),
20194
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
20195
+ "button",
20196
+ {
20197
+ type: "button",
20198
+ className: `speak-menu-item ${engine === "neural" ? "active" : ""}`,
20199
+ onClick: () => chooseEngine("neural"),
20200
+ children: [
20201
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "speak-menu-check", children: engine === "neural" ? "✓" : "" }),
20202
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Neural voice (downloads)" })
20203
+ ]
20204
+ }
20205
+ ),
20206
+ engine === "web" && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
20207
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "speak-menu-head", children: "Voice" }),
20208
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
20209
+ "select",
20210
+ {
20211
+ className: "speak-select",
20212
+ value: voiceURI || "",
20213
+ onChange: (e) => chooseVoice(e.target.value),
20214
+ children: [
20215
+ /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: "", children: "System default" }),
20216
+ voices.map((v2) => /* @__PURE__ */ jsxRuntimeExports.jsxs("option", { value: v2.voiceURI, children: [
20217
+ v2.name,
20218
+ " ",
20219
+ v2.lang ? `(${v2.lang})` : ""
20220
+ ] }, v2.voiceURI))
20221
+ ]
20222
+ }
20223
+ )
20224
+ ] }),
20225
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "speak-menu-head", children: [
20226
+ "Rate · ",
20227
+ rate.toFixed(1),
20228
+ "×"
20229
+ ] }),
20230
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
20231
+ "input",
20232
+ {
20233
+ className: "speak-rate",
20234
+ type: "range",
20235
+ min: 0.5,
20236
+ max: 2,
20237
+ step: 0.1,
20238
+ value: rate,
20239
+ onChange: (e) => changeRate(Number(e.target.value))
20240
+ }
20241
+ ),
20242
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "speak-menu-row", children: [
20243
+ /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "speak-menu-btn", onClick: testVoice, children: "Test" }),
20244
+ /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "speak-menu-btn", onClick: () => speech.stop(), children: "Stop" })
20245
+ ] })
20246
+ ] }),
20247
+ error && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "speak-error", children: error })
19568
20248
  ] });
19569
20249
  }
19570
20250
  function CtlsurfPanel({ navigate }) {
@@ -207161,7 +207841,7 @@ const lessDefaults = new LanguageServiceDefaultsImpl$3(
207161
207841
  modeConfigurationDefault$2
207162
207842
  );
207163
207843
  function getMode$3() {
207164
- return __vitePreload(() => import("./cssMode-eTXVdAkZ.js"), true ? __vite__mapDeps([0,1]) : void 0, import.meta.url);
207844
+ return __vitePreload(() => import("./cssMode-BdYp0fp9.js"), true ? __vite__mapDeps([0,1]) : void 0, import.meta.url);
207165
207845
  }
207166
207846
  languages.onLanguage("less", () => {
207167
207847
  getMode$3().then((mode2) => mode2.setupMode(lessDefaults));
@@ -207266,7 +207946,7 @@ const razorLanguageService = registerHTMLLanguageService(
207266
207946
  );
207267
207947
  const razorDefaults = razorLanguageService.defaults;
207268
207948
  function getMode$2() {
207269
- return __vitePreload(() => import("./htmlMode-46N3XG2c.js"), true ? __vite__mapDeps([2,1]) : void 0, import.meta.url);
207949
+ return __vitePreload(() => import("./htmlMode-CCm0aJ6C.js"), true ? __vite__mapDeps([2,1]) : void 0, import.meta.url);
207270
207950
  }
207271
207951
  function registerHTMLLanguageService(languageId, options = optionsDefault, modeConfiguration = getConfigurationDefault(languageId)) {
207272
207952
  const defaults = new LanguageServiceDefaultsImpl$2(languageId, options, modeConfiguration);
@@ -207350,7 +208030,7 @@ const jsonDefaults = new LanguageServiceDefaultsImpl$1(
207350
208030
  );
207351
208031
  const getWorker$1 = () => getMode$1().then((mode2) => mode2.getWorker());
207352
208032
  function getMode$1() {
207353
- return __vitePreload(() => import("./jsonMode-DXDczSNu.js"), true ? __vite__mapDeps([3,1]) : void 0, import.meta.url);
208033
+ return __vitePreload(() => import("./jsonMode-BgCUHaFo.js"), true ? __vite__mapDeps([3,1]) : void 0, import.meta.url);
207354
208034
  }
207355
208035
  languages.register({
207356
208036
  id: "json",
@@ -207596,7 +208276,7 @@ const getJavaScriptWorker = () => {
207596
208276
  return getMode().then((mode) => mode.getJavaScriptWorker());
207597
208277
  };
207598
208278
  function getMode() {
207599
- return __vitePreload(() => import("./tsMode-C7m6Kr5E.js"), true ? [] : void 0, import.meta.url);
208279
+ return __vitePreload(() => import("./tsMode-8FLRnobw.js"), true ? [] : void 0, import.meta.url);
207600
208280
  }
207601
208281
  languages.onLanguage("typescript", () => {
207602
208282
  return getMode().then((mode) => mode.setupTypeScript(typescriptDefaults));
@@ -207791,49 +208471,49 @@ registerLanguage({
207791
208471
  extensions: [".ftl", ".ftlh", ".ftlx"],
207792
208472
  aliases: ["FreeMarker2", "Apache FreeMarker2"],
207793
208473
  loader: () => {
207794
- return __vitePreload(() => import("./freemarker2-B5BKaiK4.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAutoInterpolationDollar);
208474
+ return __vitePreload(() => import("./freemarker2-Cya-9zr-.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAutoInterpolationDollar);
207795
208475
  }
207796
208476
  });
207797
208477
  registerLanguage({
207798
208478
  id: "freemarker2.tag-angle.interpolation-dollar",
207799
208479
  aliases: ["FreeMarker2 (Angle/Dollar)", "Apache FreeMarker2 (Angle/Dollar)"],
207800
208480
  loader: () => {
207801
- return __vitePreload(() => import("./freemarker2-B5BKaiK4.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAngleInterpolationDollar);
208481
+ return __vitePreload(() => import("./freemarker2-Cya-9zr-.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAngleInterpolationDollar);
207802
208482
  }
207803
208483
  });
207804
208484
  registerLanguage({
207805
208485
  id: "freemarker2.tag-bracket.interpolation-dollar",
207806
208486
  aliases: ["FreeMarker2 (Bracket/Dollar)", "Apache FreeMarker2 (Bracket/Dollar)"],
207807
208487
  loader: () => {
207808
- return __vitePreload(() => import("./freemarker2-B5BKaiK4.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagBracketInterpolationDollar);
208488
+ return __vitePreload(() => import("./freemarker2-Cya-9zr-.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagBracketInterpolationDollar);
207809
208489
  }
207810
208490
  });
207811
208491
  registerLanguage({
207812
208492
  id: "freemarker2.tag-angle.interpolation-bracket",
207813
208493
  aliases: ["FreeMarker2 (Angle/Bracket)", "Apache FreeMarker2 (Angle/Bracket)"],
207814
208494
  loader: () => {
207815
- return __vitePreload(() => import("./freemarker2-B5BKaiK4.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAngleInterpolationBracket);
208495
+ return __vitePreload(() => import("./freemarker2-Cya-9zr-.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAngleInterpolationBracket);
207816
208496
  }
207817
208497
  });
207818
208498
  registerLanguage({
207819
208499
  id: "freemarker2.tag-bracket.interpolation-bracket",
207820
208500
  aliases: ["FreeMarker2 (Bracket/Bracket)", "Apache FreeMarker2 (Bracket/Bracket)"],
207821
208501
  loader: () => {
207822
- return __vitePreload(() => import("./freemarker2-B5BKaiK4.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagBracketInterpolationBracket);
208502
+ return __vitePreload(() => import("./freemarker2-Cya-9zr-.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagBracketInterpolationBracket);
207823
208503
  }
207824
208504
  });
207825
208505
  registerLanguage({
207826
208506
  id: "freemarker2.tag-auto.interpolation-dollar",
207827
208507
  aliases: ["FreeMarker2 (Auto/Dollar)", "Apache FreeMarker2 (Auto/Dollar)"],
207828
208508
  loader: () => {
207829
- return __vitePreload(() => import("./freemarker2-B5BKaiK4.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAutoInterpolationDollar);
208509
+ return __vitePreload(() => import("./freemarker2-Cya-9zr-.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAutoInterpolationDollar);
207830
208510
  }
207831
208511
  });
207832
208512
  registerLanguage({
207833
208513
  id: "freemarker2.tag-auto.interpolation-bracket",
207834
208514
  aliases: ["FreeMarker2 (Auto/Bracket)", "Apache FreeMarker2 (Auto/Bracket)"],
207835
208515
  loader: () => {
207836
- return __vitePreload(() => import("./freemarker2-B5BKaiK4.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAutoInterpolationBracket);
208516
+ return __vitePreload(() => import("./freemarker2-Cya-9zr-.js"), true ? [] : void 0, import.meta.url).then((m) => m.TagAutoInterpolationBracket);
207837
208517
  }
207838
208518
  });
207839
208519
  registerLanguage({
@@ -207854,7 +208534,7 @@ registerLanguage({
207854
208534
  extensions: [".handlebars", ".hbs"],
207855
208535
  aliases: ["Handlebars", "handlebars", "hbs"],
207856
208536
  mimetypes: ["text/x-handlebars-template"],
207857
- loader: () => __vitePreload(() => import("./handlebars-BIdLd2wU.js"), true ? [] : void 0, import.meta.url)
208537
+ loader: () => __vitePreload(() => import("./handlebars-EJs9W5Ub.js"), true ? [] : void 0, import.meta.url)
207858
208538
  });
207859
208539
  registerLanguage({
207860
208540
  id: "hcl",
@@ -207867,7 +208547,7 @@ registerLanguage({
207867
208547
  extensions: [".html", ".htm", ".shtml", ".xhtml", ".mdoc", ".jsp", ".asp", ".aspx", ".jshtm"],
207868
208548
  aliases: ["HTML", "htm", "html", "xhtml"],
207869
208549
  mimetypes: ["text/html", "text/x-jshtm", "text/template", "text/ng-template"],
207870
- loader: () => __vitePreload(() => import("./html-BXL4cnLS.js"), true ? [] : void 0, import.meta.url)
208550
+ loader: () => __vitePreload(() => import("./html-Ex4qy2lh.js"), true ? [] : void 0, import.meta.url)
207871
208551
  });
207872
208552
  registerLanguage({
207873
208553
  id: "ini",
@@ -207890,7 +208570,7 @@ registerLanguage({
207890
208570
  filenames: ["jakefile"],
207891
208571
  aliases: ["JavaScript", "javascript", "js"],
207892
208572
  mimetypes: ["text/javascript"],
207893
- loader: () => __vitePreload(() => import("./javascript-n_iZZzDX.js"), true ? __vite__mapDeps([4,5]) : void 0, import.meta.url)
208573
+ loader: () => __vitePreload(() => import("./javascript-B2niCouI.js"), true ? __vite__mapDeps([4,5]) : void 0, import.meta.url)
207894
208574
  });
207895
208575
  registerLanguage({
207896
208576
  id: "julia",
@@ -207929,7 +208609,7 @@ registerLanguage({
207929
208609
  extensions: [".liquid", ".html.liquid"],
207930
208610
  aliases: ["Liquid", "liquid"],
207931
208611
  mimetypes: ["application/liquid"],
207932
- loader: () => __vitePreload(() => import("./liquid-B1QweUh7.js"), true ? [] : void 0, import.meta.url)
208612
+ loader: () => __vitePreload(() => import("./liquid-DOQmSo4G.js"), true ? [] : void 0, import.meta.url)
207933
208613
  });
207934
208614
  registerLanguage({
207935
208615
  id: "m3",
@@ -207947,7 +208627,7 @@ registerLanguage({
207947
208627
  id: "mdx",
207948
208628
  extensions: [".mdx"],
207949
208629
  aliases: ["MDX", "mdx"],
207950
- loader: () => __vitePreload(() => import("./mdx-BCv8lm5e.js"), true ? [] : void 0, import.meta.url)
208630
+ loader: () => __vitePreload(() => import("./mdx-DuKdGNKE.js"), true ? [] : void 0, import.meta.url)
207951
208631
  });
207952
208632
  registerLanguage({
207953
208633
  id: "mips",
@@ -208046,7 +208726,7 @@ registerLanguage({
208046
208726
  extensions: [".py", ".rpy", ".pyw", ".cpy", ".gyp", ".gypi"],
208047
208727
  aliases: ["Python", "py"],
208048
208728
  firstLine: "^#!/.*\\bpython[0-9.-]*\\b",
208049
- loader: () => __vitePreload(() => import("./python-BLNzYwDv.js"), true ? [] : void 0, import.meta.url)
208729
+ loader: () => __vitePreload(() => import("./python-BUa4XFG-.js"), true ? [] : void 0, import.meta.url)
208050
208730
  });
208051
208731
  registerLanguage({
208052
208732
  id: "qsharp",
@@ -208065,7 +208745,7 @@ registerLanguage({
208065
208745
  extensions: [".cshtml"],
208066
208746
  aliases: ["Razor", "razor"],
208067
208747
  mimetypes: ["text/x-cshtml"],
208068
- loader: () => __vitePreload(() => import("./razor-CvAww8bG.js"), true ? [] : void 0, import.meta.url)
208748
+ loader: () => __vitePreload(() => import("./razor-BUw8jho0.js"), true ? [] : void 0, import.meta.url)
208069
208749
  });
208070
208750
  registerLanguage({
208071
208751
  id: "redis",
@@ -208198,7 +208878,7 @@ registerLanguage({
208198
208878
  aliases: ["TypeScript", "ts", "typescript"],
208199
208879
  mimetypes: ["text/typescript"],
208200
208880
  loader: () => {
208201
- return __vitePreload(() => import("./typescript-DhPw4VVg.js"), true ? [] : void 0, import.meta.url);
208881
+ return __vitePreload(() => import("./typescript-rdwAjCi4.js"), true ? [] : void 0, import.meta.url);
208202
208882
  }
208203
208883
  });
208204
208884
  registerLanguage({
@@ -208243,14 +208923,14 @@ registerLanguage({
208243
208923
  firstLine: "(\\<\\?xml.*)|(\\<svg)|(\\<\\!doctype\\s+svg)",
208244
208924
  aliases: ["XML", "xml"],
208245
208925
  mimetypes: ["text/xml", "application/xml", "application/xaml+xml", "application/xml-dtd"],
208246
- loader: () => __vitePreload(() => import("./xml-B0WLFJ2U.js"), true ? [] : void 0, import.meta.url)
208926
+ loader: () => __vitePreload(() => import("./xml-HOzT59Qg.js"), true ? [] : void 0, import.meta.url)
208247
208927
  });
208248
208928
  registerLanguage({
208249
208929
  id: "yaml",
208250
208930
  extensions: [".yaml", ".yml"],
208251
208931
  aliases: ["YAML", "yaml", "YML", "yml"],
208252
208932
  mimetypes: ["application/x-yaml", "text/x-yaml"],
208253
- loader: () => __vitePreload(() => import("./yaml-BWyn9Wd7.js"), true ? [] : void 0, import.meta.url)
208933
+ loader: () => __vitePreload(() => import("./yaml-B81JfLsW.js"), true ? [] : void 0, import.meta.url)
208254
208934
  });
208255
208935
  var __defProp = Object.defineProperty;
208256
208936
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -212672,15 +213352,19 @@ function App() {
212672
213352
  const visiblePaneIds = findPaneIds(layout2);
212673
213353
  reactExports.useEffect(() => {
212674
213354
  async function init2() {
212675
- const list2 = await window.worker.listAgents();
212676
- setAgents(list2);
212677
- const status2 = await window.worker.getWorkerStatus();
212678
- setWsStatus(status2);
212679
- const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath());
212680
- setCwd(initialCwd);
212681
- cwdRef.current = initialCwd;
212682
- window.worker.getProjectName().then(setProjectName).catch(() => {
212683
- });
213355
+ try {
213356
+ const list2 = await window.worker.listAgents();
213357
+ setAgents(list2);
213358
+ const status2 = await window.worker.getWorkerStatus();
213359
+ setWsStatus(status2);
213360
+ const initialCwd = await window.worker.getCwd().catch(() => window.worker.getHomePath());
213361
+ setCwd(initialCwd);
213362
+ cwdRef.current = initialCwd;
213363
+ window.worker.getProjectName().then(setProjectName).catch(() => {
213364
+ });
213365
+ } finally {
213366
+ window.__dismissSplash?.();
213367
+ }
212684
213368
  }
212685
213369
  init2();
212686
213370
  }, []);
@@ -212737,8 +213421,9 @@ function App() {
212737
213421
  const handleVoiceTranscript = reactExports.useCallback((text2) => {
212738
213422
  const trimmed = text2.trim();
212739
213423
  if (!trimmed) return;
212740
- window.worker.writePty(activeTabId, trimmed + "\r");
213424
+ window.worker.writePty(activeTabId, trimmed);
212741
213425
  focusTerminal(activeTabId);
213426
+ setTimeout(() => window.worker.writePty(activeTabId, "\r"), 80);
212742
213427
  }, [activeTabId]);
212743
213428
  const cwdRef = reactExports.useRef(null);
212744
213429
  const handleSpawn = reactExports.useCallback(async (tabId, agent) => {
@@ -213030,6 +213715,7 @@ function App() {
213030
213715
  )
213031
213716
  }
213032
213717
  ),
213718
+ /* @__PURE__ */ jsxRuntimeExports.jsx(SpeakControls, {}),
213033
213719
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "titlebar-separator" }),
213034
213720
  agents.map((a) => {
213035
213721
  const activeTab = tabs.find((t) => t.id === activeTabId);