diff --git a/linux-features/read-aloud-mcp/README.md b/linux-features/read-aloud-mcp/README.md index 7f584eff..4653b48e 100644 --- a/linux-features/read-aloud-mcp/README.md +++ b/linux-features/read-aloud-mcp/README.md @@ -6,7 +6,7 @@ This feature stages a separate `read-aloud` Codex plugin with a native Rust MCP server. It does not enable microphone input or conversation mode. The first MCP surface is intentionally small: -- `doctor` reports whether Kokoro, a custom command, or explicit native fallback +- `doctor` reports whether Kokoro, a custom command, or native fallback is available. - `read_aloud` speaks text only when the user or agent explicitly asks for it. - `stop` interrupts playback started by the MCP server. @@ -63,10 +63,12 @@ The MCP server reads the same overrides as the UI feature: - `CODEX_LINUX_READ_ALOUD_KOKORO_VOICE` - `CODEX_LINUX_READ_ALOUD_KOKORO_SPEED` - `CODEX_LINUX_READ_ALOUD_KOKORO_LANG` -- `CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK=1` +- `CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK=0` -Native `spd-say` / `espeak-ng` fallback stays disabled unless explicitly -requested because the voice quality is usually worse than Kokoro. +Native `spd-say` / `espeak-ng` fallback is available by default after this +opt-in MCP plugin is enabled, but Kokoro remains preferred. Set +`CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK=0` to disable the machine voice +fallback. ## Validate diff --git a/linux-features/read-aloud/README.md b/linux-features/read-aloud/README.md index 5134f836..01b856b9 100644 --- a/linux-features/read-aloud/README.md +++ b/linux-features/read-aloud/README.md @@ -123,12 +123,14 @@ cleaned response text to stdin: CODEX_LINUX_READ_ALOUD_COMMAND="/path/to/tts-stdin-command" codex-desktop ``` -System TTS fallbacks are disabled by default because `spd-say` and `espeak-ng` -are widely available but usually unpleasant. Enable them explicitly only when -that tradeoff is acceptable: +When Kokoro is not ready, Read Aloud can fall back to system TTS through +`spd-say` or `espeak-ng`. This fallback is enabled by default only after the +user has opted into the Read Aloud feature/runtime; Kokoro remains the preferred +backend when it is available. Disable the native fallback if the machine voice +is not acceptable: ```bash -CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK=1 codex-desktop +CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK=0 codex-desktop ``` The handler never invokes a shell for response text. diff --git a/linux-features/read-aloud/install-kokoro-runtime.sh b/linux-features/read-aloud/install-kokoro-runtime.sh index 5333d48a..3a6e1014 100755 --- a/linux-features/read-aloud/install-kokoro-runtime.sh +++ b/linux-features/read-aloud/install-kokoro-runtime.sh @@ -33,14 +33,16 @@ download_file() { rm -f "$tmp" if command -v curl >/dev/null 2>&1; then - curl --fail --location --show-error --output "$tmp" "$url" + curl --fail --location --show-error --user-agent "codex-desktop-read-aloud" --output "$tmp" "$url" elif command -v wget >/dev/null 2>&1; then - wget --output-document "$tmp" "$url" + wget --user-agent="codex-desktop-read-aloud" --output-document "$tmp" "$url" else "$python_bin" - "$url" "$tmp" <<'PY' import sys import urllib.request -urllib.request.urlretrieve(sys.argv[1], sys.argv[2]) +request = urllib.request.Request(sys.argv[1], headers={"User-Agent": "codex-desktop-read-aloud"}) +with urllib.request.urlopen(request) as response, open(sys.argv[2], "wb") as output: + output.write(response.read()) PY fi diff --git a/linux-features/read-aloud/patch.js b/linux-features/read-aloud/patch.js index 56212057..e84da07d 100644 --- a/linux-features/read-aloud/patch.js +++ b/linux-features/read-aloud/patch.js @@ -19,7 +19,7 @@ const KOKORO_VOICES_URL = const HELPER_MARKER = "codexLinuxReadAloudClick"; const SETUP_MARKER = "codexLinuxReadAloudSetup"; const HANDLER_NAME = "linux-read-aloud"; -const RUNTIME_VERSION = "kokoro-explicit-v4"; +const RUNTIME_VERSION = "kokoro-explicit-v5"; const READ_ALOUD_SETTINGS_SLUG = "read-aloud-settings"; const GENERAL_SETTINGS_ROW_CALL = "(0,$.jsx)(codexLinuxReadAloudSettingsRow,{})"; const GENERAL_SETTINGS_CHILDREN = "children:[S,C,w,T,D,O,k,A,j,M,N,P,L]"; @@ -57,8 +57,11 @@ function applyMainBundlePatch(source) { `function codexLinuxReadAloudWriteSettings(e){let t=codexLinuxReadAloudSettingsPath();if(!t)throw Error(\`settings path unavailable\`);${fsVar}.mkdirSync(${pathVar}.dirname(t),{recursive:!0});${fsVar}.writeFileSync(t,JSON.stringify(e,null,2)+\`\\n\`)}`, `function codexLinuxReadAloudEnabled(){if(process.env.CODEX_LINUX_READ_ALOUD_ENABLED===\`1\`)return!0;let e=codexLinuxReadAloudSettings();return e[${JSON.stringify(SETTINGS_KEY)}]===!0}`, `function codexLinuxReadAloudFileExists(e){try{return!!e&&${fsVar}.existsSync(e)}catch{return!1}}`, - `function codexLinuxReadAloudCommandExists(e){if(!e)return!1;if(e.includes(\`/\`))try{return!!e&&${fsVar}.existsSync(e)&&(${fsVar}.accessSync(e,${fsVar}.constants.X_OK),!0)}catch{return!1};try{return ${childProcessVar}.spawnSync(\`which\`,[e],{stdio:\`ignore\`}).status===0}catch{return!1}}`, - `function codexLinuxReadAloudKokoroRunner(){let e=process.env.CODEX_LINUX_READ_ALOUD_KOKORO_RUNNER?.trim();if(e)return e;let t=process.resourcesPath;return t?${pathVar}.join(t,\`read-aloud\`,\`kokoro-stdin\`):\`\`}`, + `function codexLinuxReadAloudCommandExists(command){if(!command)return!1;if(command.includes(\`/\`))try{return!!command&&${fsVar}.existsSync(command)&&(${fsVar}.accessSync(command,${fsVar}.constants.X_OK),!0)}catch{return!1};try{return ${childProcessVar}.spawnSync(\`which\`,[command],{stdio:\`ignore\`}).status===0}catch{return!1}}`, + `function codexLinuxReadAloudNativeFallbackEnabled(){let e=process.env.CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK?.trim().toLowerCase();return!(e===\`0\`||e===\`false\`||e===\`off\`||e===\`no\`)}`, + `function codexLinuxReadAloudNativeFallbackAvailable(){return codexLinuxReadAloudNativeFallbackEnabled()&&(codexLinuxReadAloudCommandExists(\`spd-say\`)||codexLinuxReadAloudCommandExists(\`espeak-ng\`))}`, + `function codexLinuxReadAloudAudioEnv(){if(process.env.XDG_RUNTIME_DIR)return{};let e;try{e=process.getuid?.()}catch{}if(e==null)return{};let t=\`/run/user/\${e}\`;try{if(${fsVar}.existsSync(${pathVar}.join(t,\`pipewire-0\`))||${fsVar}.existsSync(${pathVar}.join(t,\`pulse\`,\`native\`)))return{XDG_RUNTIME_DIR:t}}catch{}return{}}`, + `function codexLinuxReadAloudKokoroRunner(){let e=process.env.CODEX_LINUX_READ_ALOUD_KOKORO_RUNNER?.trim();if(e)return e;let t=process.resourcesPath?${pathVar}.join(process.resourcesPath,\`read-aloud\`,\`kokoro-stdin\`):\`\`;return t&&codexLinuxReadAloudCommandExists(t)?t:\`kokoro-stdin\`}`, `function codexLinuxReadAloudKokoroPython(){let e=process.env.CODEX_LINUX_READ_ALOUD_KOKORO_PYTHON?.trim(),t=codexLinuxReadAloudSettings()[${JSON.stringify(KOKORO_PYTHON_KEY)}];return e||typeof t===\`string\`&&t.trim()||${pathVar}.join(codexLinuxReadAloudDataHome(),\`codex-desktop\`,\`read-aloud\`,\`kokoro-venv\`,\`bin\`,\`python\`)}`, `function codexLinuxReadAloudKokoroModel(){let e=process.env.CODEX_LINUX_READ_ALOUD_KOKORO_MODEL?.trim(),t=codexLinuxReadAloudSettings()[${JSON.stringify(KOKORO_MODEL_KEY)}];return e||typeof t===\`string\`&&t.trim()||${pathVar}.join(codexLinuxReadAloudDataHome(),\`kokoro\`,\`kokoro-v1.0.onnx\`)}`, `function codexLinuxReadAloudKokoroVoices(){let e=process.env.CODEX_LINUX_READ_ALOUD_KOKORO_VOICES?.trim(),t=codexLinuxReadAloudSettings()[${JSON.stringify(KOKORO_VOICES_KEY)}];return e||typeof t===\`string\`&&t.trim()||${pathVar}.join(codexLinuxReadAloudDataHome(),\`kokoro\`,\`voices-v1.0.bin\`)}`, @@ -68,22 +71,24 @@ function applyMainBundlePatch(source) { `function codexLinuxReadAloudKokoroMissing(){let e=[];codexLinuxReadAloudCommandExists(codexLinuxReadAloudKokoroRunner())||e.push(\`runner\`);codexLinuxReadAloudCommandExists(codexLinuxReadAloudKokoroPython())||e.push(\`python\`);codexLinuxReadAloudFileExists(codexLinuxReadAloudKokoroModel())||e.push(\`model\`);codexLinuxReadAloudFileExists(codexLinuxReadAloudKokoroVoices())||e.push(\`voices\`);codexLinuxReadAloudCommandExists(\`aplay\`)||e.push(\`aplay\`);return e}`, `function codexLinuxReadAloudKokoroModelUrl(){return process.env.CODEX_LINUX_READ_ALOUD_KOKORO_MODEL_URL?.trim()||${JSON.stringify(KOKORO_MODEL_URL)}}`, `function codexLinuxReadAloudKokoroVoicesUrl(){return process.env.CODEX_LINUX_READ_ALOUD_KOKORO_VOICES_URL?.trim()||${JSON.stringify(KOKORO_VOICES_URL)}}`, - `function codexLinuxReadAloudConfig(){let e=codexLinuxReadAloudKokoroMissing();return{enabled:codexLinuxReadAloudEnabled(),engine:(process.env.CODEX_LINUX_READ_ALOUD_ENGINE?.trim()||\`kokoro\`).toLowerCase(),nativeFallback:process.env.CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK===\`1\`,customCommand:!!process.env.CODEX_LINUX_READ_ALOUD_COMMAND?.trim(),kokoro:{available:e.length===0,missing:e,voice:codexLinuxReadAloudKokoroVoice(),speed:codexLinuxReadAloudKokoroSpeed(),python:codexLinuxReadAloudKokoroPython(),model:codexLinuxReadAloudKokoroModel(),voices:codexLinuxReadAloudKokoroVoices(),modelUrl:codexLinuxReadAloudKokoroModelUrl(),voicesUrl:codexLinuxReadAloudKokoroVoicesUrl()}}}`, - `function codexLinuxReadAloudRun(e,t,n=3e5){return new Promise((r,i)=>{let a,o;try{a=${childProcessVar}.spawn(e,t,{stdio:\`ignore\`,windowsHide:!0}),o=setTimeout(()=>{try{a.kill(\`SIGTERM\`)}catch{}i(Error(\`command timed out\`))},n),o.unref?.(),a.on(\`error\`,e=>{clearTimeout(o),i(e)}),a.on(\`close\`,e=>{clearTimeout(o),e===0?r():i(Error(\`command failed\`))})}catch(e){clearTimeout(o),i(e)}})}`, - `function codexLinuxReadAloudPythonOk(e){try{return ${childProcessVar}.spawnSync(e,[\`-c\`,\`import sys; raise SystemExit(0 if (3,10) <= sys.version_info < (3,14) else 1)\`],{stdio:\`ignore\`}).status===0}catch{return!1}}`, + `function codexLinuxReadAloudConfig(){let e=codexLinuxReadAloudKokoroMissing();return{enabled:codexLinuxReadAloudEnabled(),engine:(process.env.CODEX_LINUX_READ_ALOUD_ENGINE?.trim()||\`kokoro\`).toLowerCase(),nativeFallback:codexLinuxReadAloudNativeFallbackEnabled(),customCommand:!!process.env.CODEX_LINUX_READ_ALOUD_COMMAND?.trim(),kokoro:{available:e.length===0,missing:e,voice:codexLinuxReadAloudKokoroVoice(),speed:codexLinuxReadAloudKokoroSpeed(),python:codexLinuxReadAloudKokoroPython(),model:codexLinuxReadAloudKokoroModel(),voices:codexLinuxReadAloudKokoroVoices(),modelUrl:codexLinuxReadAloudKokoroModelUrl(),voicesUrl:codexLinuxReadAloudKokoroVoicesUrl()}}}`, + `function codexLinuxReadAloudBackendReady(e){let t=process.env.CODEX_LINUX_READ_ALOUD_COMMAND?.trim();return!!(e?.kokoro?.available||codexLinuxReadAloudNativeFallbackAvailable()||t&&codexLinuxReadAloudCommandExists(t))}`, + `function codexLinuxReadAloudSetupResult(){let e=codexLinuxReadAloudConfig();return codexLinuxReadAloudBackendReady(e)?{ok:!0,config:e}:{ok:!1,reason:\`voice-unavailable\`,config:e}}`, + `function codexLinuxReadAloudRun(command,args,timeoutMs=3e5){return new Promise((resolve,reject)=>{let child,timer;try{child=${childProcessVar}.spawn(command,args,{stdio:\`ignore\`,windowsHide:!0,env:{...process.env,...codexLinuxReadAloudAudioEnv()}}),timer=setTimeout(()=>{try{child.kill(\`SIGTERM\`)}catch{}reject(Error(\`command timed out\`))},timeoutMs),timer.unref?.(),child.on(\`error\`,error=>{clearTimeout(timer),reject(error)}),child.on(\`close\`,code=>{clearTimeout(timer),code===0?resolve():reject(Error(\`command failed\`))})}catch(error){clearTimeout(timer),reject(error)}})}`, + `function codexLinuxReadAloudPythonOk(pythonBin){try{return ${childProcessVar}.spawnSync(pythonBin,[\`-c\`,\`import sys; raise SystemExit(0 if (3,10) <= sys.version_info < (3,14) else 1)\`],{stdio:\`ignore\`,env:{...process.env,...codexLinuxReadAloudAudioEnv()}}).status===0}catch{return!1}}`, `function codexLinuxReadAloudFindPython(){for(let e of [process.env.PYTHON?.trim(),\`python3.12\`,\`python3.13\`,\`python3.11\`,\`python3.10\`,\`python3\`])if(e&&codexLinuxReadAloudCommandExists(e)&&codexLinuxReadAloudPythonOk(e))return e;return null}`, `async function codexLinuxReadAloudInstallRuntime(){let e=codexLinuxReadAloudKokoroPython();if(codexLinuxReadAloudCommandExists(e))return;let t=${pathVar}.dirname(${pathVar}.dirname(e)),n=codexLinuxReadAloudFindPython();if(!n)throw Error(\`Python 3.10-3.13 is required for Kokoro\`);${fsVar}.mkdirSync(${pathVar}.dirname(t),{recursive:!0});if(codexLinuxReadAloudCommandExists(\`uv\`)){await codexLinuxReadAloudRun(\`uv\`,[\`venv\`,\`--python\`,n,t]);await codexLinuxReadAloudRun(\`uv\`,[\`pip\`,\`install\`,\`--python\`,e,\`kokoro-onnx>=0.5.0\`,\`numpy>=2.0.2\`],6e5);return}await codexLinuxReadAloudRun(n,[\`-m\`,\`venv\`,t]);await codexLinuxReadAloudRun(e,[\`-m\`,\`ensurepip\`,\`--upgrade\`]);await codexLinuxReadAloudRun(e,[\`-m\`,\`pip\`,\`install\`,\`--upgrade\`,\`pip\`],6e5);await codexLinuxReadAloudRun(e,[\`-m\`,\`pip\`,\`install\`,\`kokoro-onnx>=0.5.0\`,\`numpy>=2.0.2\`],6e5)}`, - `function codexLinuxReadAloudDownloadFile(url,target,minBytes,redirects=0){return new Promise((resolve,reject)=>{if(redirects>5){reject(Error(\`too many redirects\`));return}let parsed;try{parsed=new URL(url)}catch{reject(Error(\`invalid download URL\`));return}let get=parsed.protocol===\`https:\`?require(\`node:https\`).get:parsed.protocol===\`http:\`?require(\`node:http\`).get:null;if(!get){reject(Error(\`unsupported download URL\`));return}${fsVar}.mkdirSync(${pathVar}.dirname(target),{recursive:!0});let partial=\`\${target}.part\`,bytes=0,done=!1;try{${fsVar}.rmSync(partial,{force:!0})}catch{}let cleanup=()=>{try{${fsVar}.rmSync(partial,{force:!0})}catch{}};let fail=e=>{if(done)return;done=!0;cleanup();reject(e instanceof Error?e:Error(String(e)))};let fileStream=${fsVar}.createWriteStream(partial),request=get(parsed,response=>{if(response.statusCode>=300&&response.statusCode<400&&response.headers.location){response.resume?.();fileStream.close(()=>{});cleanup();codexLinuxReadAloudDownloadFile(new URL(response.headers.location,parsed).toString(),target,minBytes,redirects+1).then(resolve,reject);return}if(response.statusCode!==200){response.resume?.();fileStream.close(()=>{});fail(Error(\`download failed with status \${response.statusCode}\`));return}response.on(\`data\`,chunk=>{bytes+=chunk.length}),response.pipe(fileStream),fileStream.on(\`finish\`,()=>fileStream.close(()=>{if(done)return;if(bytes{if(redirects>5){reject(Error(\`too many redirects\`));return}let parsed;try{parsed=new URL(url)}catch{reject(Error(\`invalid download URL\`));return}let get=parsed.protocol===\`https:\`?require(\`node:https\`).get:parsed.protocol===\`http:\`?require(\`node:http\`).get:null;if(!get){reject(Error(\`unsupported download URL\`));return}${fsVar}.mkdirSync(${pathVar}.dirname(target),{recursive:!0});let partial=\`\${target}.part\`,bytes=0,done=!1;try{${fsVar}.rmSync(partial,{force:!0})}catch{}let cleanup=()=>{try{${fsVar}.rmSync(partial,{force:!0})}catch{}};let fail=e=>{if(done)return;done=!0;cleanup();reject(e instanceof Error?e:Error(String(e)))};let fileStream=${fsVar}.createWriteStream(partial),request=get(parsed,{headers:{"User-Agent":\`codex-desktop-read-aloud\`}},response=>{if(response.statusCode>=300&&response.statusCode<400&&response.headers.location){response.resume?.();fileStream.close(()=>{});cleanup();codexLinuxReadAloudDownloadFile(new URL(response.headers.location,parsed).toString(),target,minBytes,redirects+1).then(resolve,reject);return}if(response.statusCode!==200){response.resume?.();fileStream.close(()=>{});fail(Error(\`download failed with status \${response.statusCode}\`));return}response.on(\`data\`,chunk=>{bytes+=chunk.length}),response.pipe(fileStream),fileStream.on(\`finish\`,()=>fileStream.close(()=>{if(done)return;if(bytes{codexLinuxReadAloudProc===r&&(codexLinuxReadAloudProc=null)}),r.unref?.();return!0}catch{return!1}}`, - `function codexLinuxReadAloudSpawnStdin(e,t,n,r={}){if(!codexLinuxReadAloudCommandExists(e))return!1;try{codexLinuxReadAloudStop();let i=${childProcessVar}.spawn(e,t,{stdio:[\`pipe\`,\`ignore\`,\`ignore\`],windowsHide:!0,detached:!0,env:{...process.env,...r}});codexLinuxReadAloudProc=i,i.on?.(\`exit\`,()=>{codexLinuxReadAloudProc===i&&(codexLinuxReadAloudProc=null)}),i.stdin?.end(n),i.unref?.();return!0}catch{return!1}}`, - `function codexLinuxReadAloudKokoro(e){let t=codexLinuxReadAloudKokoroMissing();if(t.length)return{spoken:!1,reason:\`kokoro-unavailable\`,missing:t};let n=codexLinuxReadAloudKokoroSpeed(),r={CODEX_LINUX_READ_ALOUD_KOKORO_MODEL:codexLinuxReadAloudKokoroModel(),CODEX_LINUX_READ_ALOUD_KOKORO_VOICES:codexLinuxReadAloudKokoroVoices(),CODEX_LINUX_READ_ALOUD_KOKORO_VOICE:codexLinuxReadAloudKokoroVoice(),CODEX_LINUX_READ_ALOUD_KOKORO_SPEED:String(n)};return codexLinuxReadAloudSpawnStdin(codexLinuxReadAloudKokoroRunner(),[],e,r)?{spoken:!0,engine:\`kokoro\`,voice:codexLinuxReadAloudKokoroVoice(),speed:n}:{spoken:!1,reason:\`kokoro-spawn-failed\`}}`, - `function codexLinuxReadAloudPiper(e,t){let n=process.env.CODEX_LINUX_READ_ALOUD_PIPER_BIN?.trim()||\`piper\`;if(!t||!${fsVar}.existsSync(t)||!codexLinuxReadAloudCommandExists(n)||!codexLinuxReadAloudCommandExists(\`aplay\`))return!1;try{codexLinuxReadAloudStop();let r=${childProcessVar}.spawn(n,[\`--model\`,t,\`--output-raw\`],{stdio:[\`pipe\`,\`pipe\`,\`ignore\`],windowsHide:!0,detached:!0}),i=${childProcessVar}.spawn(\`aplay\`,[\`-q\`,\`-r\`,\`22050\`,\`-c\`,\`1\`,\`-f\`,\`S16_LE\`,\`-t\`,\`raw\`],{stdio:[\`pipe\`,\`ignore\`,\`ignore\`],windowsHide:!0,detached:!0});codexLinuxReadAloudProc=r,r.on?.(\`exit\`,()=>{codexLinuxReadAloudProc===r&&(codexLinuxReadAloudProc=null)}),r.stdout?.pipe(i.stdin),r.stdin?.end(e),r.unref?.(),i.unref?.();return!0}catch{return!1}}`, - `function codexLinuxReadAloudSpeak(e){if(process.platform!==\`linux\`)return codexLinuxReadAloudReport({spoken:!1,reason:\`not-linux\`});if(!codexLinuxReadAloudEnabled())return codexLinuxReadAloudReport({spoken:!1,reason:\`disabled\`});let t=codexLinuxReadAloudCleanText(e);if(!t)return codexLinuxReadAloudReport({spoken:!1,reason:\`empty\`});let n=process.env.CODEX_LINUX_READ_ALOUD_COMMAND?.trim();if(n&&codexLinuxReadAloudSpawnStdin(n,[],t))return codexLinuxReadAloudReport({spoken:!0,engine:\`custom\`});let r=(process.env.CODEX_LINUX_READ_ALOUD_ENGINE?.trim()||\`kokoro\`).toLowerCase(),a=codexLinuxReadAloudHasHebrew(t),o=process.env.CODEX_LINUX_READ_ALOUD_PIPER_MODEL?.trim();if(r===\`piper\`)return codexLinuxReadAloudReport(codexLinuxReadAloudPiper(t,o)?{spoken:!0,engine:\`piper\`}:{spoken:!1,reason:\`piper-unavailable\`});let i=codexLinuxReadAloudKokoro(t);if(i.spoken)return codexLinuxReadAloudReport(i);if(process.env.CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK!==\`1\`)return codexLinuxReadAloudReport(i);if(codexLinuxReadAloudPiper(t,o))return codexLinuxReadAloudReport({spoken:!0,engine:\`piper\`});let s=process.env.CODEX_LINUX_READ_ALOUD_VOICE?.trim(),c=process.env.CODEX_LINUX_READ_ALOUD_RATE?.trim()||\`-10\`,l=process.env.CODEX_LINUX_READ_ALOUD_VOICE_TYPE?.trim()||\`female1\`;try{codexLinuxReadAloudCommandExists(\`spd-say\`)&&${childProcessVar}.spawn(\`spd-say\`,[\`-C\`],{stdio:\`ignore\`,windowsHide:!0}).unref?.()}catch{}let u=[\`-r\`,c,\`-t\`,l,...(s?[\`-y\`,s]:[]),\`-l\`,a?\`he\`:\`en\`,\`--\`,t];if(codexLinuxReadAloudSpawn(\`spd-say\`,u))return codexLinuxReadAloudReport({spoken:!0,engine:\`spd-say\`});let d=s||a?\`he\`:\`en-us\`;return codexLinuxReadAloudReport(codexLinuxReadAloudSpawn(\`espeak-ng\`,[\`-v\`,d,\`-s\`,process.env.CODEX_LINUX_READ_ALOUD_ESPEAK_RATE?.trim()||\`165\`,\`--\`,t])?{spoken:!0,engine:\`espeak-ng\`}:i)}`, + `function codexLinuxReadAloudSpawn(command,args,options={}){if(!codexLinuxReadAloudCommandExists(command))return!1;try{codexLinuxReadAloudStop();let child=${childProcessVar}.spawn(command,args,{...options,stdio:options.stdio??\`ignore\`,windowsHide:!0,detached:!0,env:{...process.env,...codexLinuxReadAloudAudioEnv(),...(options.env||{})}});codexLinuxReadAloudProc=child,child.on?.(\`exit\`,()=>{codexLinuxReadAloudProc===child&&(codexLinuxReadAloudProc=null)}),child.unref?.();return!0}catch{return!1}}`, + `function codexLinuxReadAloudSpawnStdin(command,args,text,extraEnv={}){if(!codexLinuxReadAloudCommandExists(command))return!1;try{codexLinuxReadAloudStop();let child=${childProcessVar}.spawn(command,args,{stdio:[\`pipe\`,\`ignore\`,\`ignore\`],windowsHide:!0,detached:!0,env:{...process.env,...codexLinuxReadAloudAudioEnv(),...extraEnv}});codexLinuxReadAloudProc=child,child.on?.(\`exit\`,()=>{codexLinuxReadAloudProc===child&&(codexLinuxReadAloudProc=null)}),child.stdin?.end(text),child.unref?.();return!0}catch{return!1}}`, + `function codexLinuxReadAloudKokoro(e){let t=codexLinuxReadAloudKokoroMissing();if(t.length)return{spoken:!1,reason:\`kokoro-unavailable\`,missing:t};let n=codexLinuxReadAloudKokoroSpeed(),r={CODEX_LINUX_READ_ALOUD_KOKORO_PYTHON:codexLinuxReadAloudKokoroPython(),CODEX_LINUX_READ_ALOUD_KOKORO_MODEL:codexLinuxReadAloudKokoroModel(),CODEX_LINUX_READ_ALOUD_KOKORO_VOICES:codexLinuxReadAloudKokoroVoices(),CODEX_LINUX_READ_ALOUD_KOKORO_VOICE:codexLinuxReadAloudKokoroVoice(),CODEX_LINUX_READ_ALOUD_KOKORO_SPEED:String(n)};return codexLinuxReadAloudSpawnStdin(codexLinuxReadAloudKokoroRunner(),[],e,r)?{spoken:!0,engine:\`kokoro\`,voice:codexLinuxReadAloudKokoroVoice(),speed:n}:{spoken:!1,reason:\`kokoro-spawn-failed\`}}`, + `function codexLinuxReadAloudPiper(text,modelPath){let piperBin=process.env.CODEX_LINUX_READ_ALOUD_PIPER_BIN?.trim()||\`piper\`;if(!modelPath||!${fsVar}.existsSync(modelPath)||!codexLinuxReadAloudCommandExists(piperBin)||!codexLinuxReadAloudCommandExists(\`aplay\`))return!1;try{codexLinuxReadAloudStop();let piperOptions={stdio:[\`pipe\`,\`pipe\`,\`ignore\`],windowsHide:!0,detached:!0,env:{...process.env,...codexLinuxReadAloudAudioEnv()}},piper=${childProcessVar}.spawn(piperBin,[\`--model\`,modelPath,\`--output-raw\`],piperOptions),aplay=${childProcessVar}.spawn(\`aplay\`,[\`-q\`,\`-r\`,\`22050\`,\`-c\`,\`1\`,\`-f\`,\`S16_LE\`,\`-t\`,\`raw\`],{stdio:[\`pipe\`,\`ignore\`,\`ignore\`],windowsHide:!0,detached:!0,env:{...process.env,...codexLinuxReadAloudAudioEnv()}});codexLinuxReadAloudProc=piper,piper.on?.(\`exit\`,()=>{codexLinuxReadAloudProc===piper&&(codexLinuxReadAloudProc=null)}),piper.stdout?.pipe(aplay.stdin),piper.stdin?.end(text),piper.unref?.(),aplay.unref?.();return!0}catch{return!1}}`, + `function codexLinuxReadAloudSpeak(input){if(process.platform!==\`linux\`)return codexLinuxReadAloudReport({spoken:!1,reason:\`not-linux\`});if(!codexLinuxReadAloudEnabled())return codexLinuxReadAloudReport({spoken:!1,reason:\`disabled\`});let text=codexLinuxReadAloudCleanText(input);if(!text)return codexLinuxReadAloudReport({spoken:!1,reason:\`empty\`});let customCommand=process.env.CODEX_LINUX_READ_ALOUD_COMMAND?.trim();if(customCommand&&codexLinuxReadAloudSpawnStdin(customCommand,[],text))return codexLinuxReadAloudReport({spoken:!0,engine:\`custom\`});let engine=(process.env.CODEX_LINUX_READ_ALOUD_ENGINE?.trim()||\`kokoro\`).toLowerCase(),hasHebrew=codexLinuxReadAloudHasHebrew(text),piperModel=process.env.CODEX_LINUX_READ_ALOUD_PIPER_MODEL?.trim();if(engine===\`piper\`)return codexLinuxReadAloudReport(codexLinuxReadAloudPiper(text,piperModel)?{spoken:!0,engine:\`piper\`}:{spoken:!1,reason:\`piper-unavailable\`});let kokoroResult=codexLinuxReadAloudKokoro(text);if(kokoroResult.spoken)return codexLinuxReadAloudReport(kokoroResult);if(!codexLinuxReadAloudNativeFallbackEnabled())return codexLinuxReadAloudReport(kokoroResult);if(codexLinuxReadAloudPiper(text,piperModel))return codexLinuxReadAloudReport({spoken:!0,engine:\`piper\`});let voice=process.env.CODEX_LINUX_READ_ALOUD_VOICE?.trim(),rate=process.env.CODEX_LINUX_READ_ALOUD_RATE?.trim()||\`-10\`,voiceType=process.env.CODEX_LINUX_READ_ALOUD_VOICE_TYPE?.trim();try{codexLinuxReadAloudCommandExists(\`spd-say\`)&&${childProcessVar}.spawn(\`spd-say\`,[\`-C\`],{stdio:\`ignore\`,windowsHide:!0,env:{...process.env,...codexLinuxReadAloudAudioEnv()}}).unref?.()}catch{}let spdArgs=[\`-r\`,rate,...(voiceType?[\`-t\`,voiceType]:[]),...(voice?[\`-y\`,voice]:[]),\`-l\`,hasHebrew?\`he\`:\`en\`,\`--\`,text];if(codexLinuxReadAloudSpawn(\`spd-say\`,spdArgs))return codexLinuxReadAloudReport({spoken:!0,engine:\`spd-say\`});let espeakVoice=voice||hasHebrew?\`he\`:\`en-us\`;return codexLinuxReadAloudReport(codexLinuxReadAloudSpawn(\`espeak-ng\`,[\`-v\`,espeakVoice,\`-s\`,process.env.CODEX_LINUX_READ_ALOUD_ESPEAK_RATE?.trim()||\`165\`,\`--\`,text])?{spoken:!0,engine:\`espeak-ng\`}:kokoroResult)}`, `function codexLinuxReadAloudHandle(e={}){return e.action===\`config\`?codexLinuxReadAloudConfig():e.action===\`setup\`?codexLinuxReadAloudSetup(e):e.action===\`stop\`?codexLinuxReadAloudStop():e.action===\`speak\`&&e.source===\`button\`?codexLinuxReadAloudSpeak(e.text):codexLinuxReadAloudReport({spoken:!1,reason:\`not-explicit\`})}`, ].join(""); @@ -122,7 +127,7 @@ function readAloudRuntimeSource() { `function estimateMs(text){let words=text.split(/\\s+/).filter(Boolean).length;return Math.max(3000,Math.min(120000,words*360))}`, `function failureLabel(result){let reason=result?.reason;if(reason==="disabled")return"Enable Read aloud in settings";if(reason==="kokoro-unavailable")return"Install Read aloud voice model";if(reason==="empty")return"Nothing to read";return"No voice available"}`, `async function click(item,copyText,conversationId,button){try{button?.blur?.();if(globalThis.codexLinuxConversationIsSpeaking?.()){globalThis.codexLinuxConversationStopSpeaking?.();resetButton(button);return}if(button?.dataset.codexLinuxReadAloudState==="speaking"){stopSpeech();return}let text=clean(copyText||item?.content||"");if(text.length<2)return;setButton(button,"loading","Starting voice");let result=await post({action:"speak",source:"button",text}).then(e=>e.body).catch(()=>({spoken:!1,reason:"request-failed"}));log("[linux-read-aloud] click",{conversationId:conversationId||null,textLength:text.length,spoken:result?.spoken===!0,engine:result?.engine||null,reason:result?.reason||null,missing:Array.isArray(result?.missing)?result.missing.join(","):null});if(result?.spoken){currentButton=button;setButton(button,"speaking");currentSpeakTimer=setTimeout(()=>resetButton(button),estimateMs(text));return}flash(button,failureLabel(result))}catch{flash(button,"No voice available")}}`, - `function setupLabel(result){let reason=result?.reason;if(reason==="cancelled")return"Cancelled";if(reason==="missing-files")return"Folder is missing model files";if(reason==="python-unavailable")return"Python 3.10-3.13 required";return result?.ok?"Voice ready":"Setup failed"}`, + `function setupLabel(result){let reason=result?.reason;if(reason==="cancelled")return"Cancelled";if(reason==="missing-files")return"Folder is missing model files";if(reason==="python-unavailable")return"Python 3.10-3.13 required";if(reason==="voice-unavailable")return"Voice backend missing";return result?.ok?"Voice ready":"Setup failed"}`, `async function setup(mode,button){let original=button?.dataset.codexLinuxReadAloudOriginalLabel||button?.textContent||"";if(button&&!button.dataset.codexLinuxReadAloudOriginalLabel)button.dataset.codexLinuxReadAloudOriginalLabel=original;try{button&&(button.disabled=!0,button.textContent=mode==="download"?"Downloading...":"Choosing...");let result=await post({action:"setup",mode},mode==="download"?9e5:6e4).then(e=>e.body).catch(()=>({ok:!1,reason:"request-failed"}));button&&(button.textContent=setupLabel(result));setTimeout(()=>{button&&(button.textContent=original,button.disabled=!1)},1800);return result}catch{button&&(button.textContent="Setup failed",setTimeout(()=>{button.textContent=original,button.disabled=!1},1800))}}`, `function installStyle(){if(document.getElementById("codex-linux-read-aloud-style"))return;let e=document.createElement("style");e.id="codex-linux-read-aloud-style";e.textContent=".codex-linux-read-aloud-row{display:flex;align-items:center;margin-top:4px}.codex-linux-read-aloud-button{width:28px;height:24px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--token-border);background:transparent;color:var(--text-secondary,var(--token-description-foreground));border-radius:6px;padding:0;cursor:pointer}.codex-linux-read-aloud-icon{width:15px;height:15px}.codex-linux-read-aloud-button:hover{background:var(--token-list-hover-background,rgba(127,127,127,.12));color:var(--text-primary,var(--token-foreground))}.codex-linux-read-aloud-button:disabled{opacity:.65;cursor:default}.codex-linux-read-aloud-button[data-codex-linux-read-aloud-state=speaking]{background:var(--token-list-hover-background,rgba(127,127,127,.14));color:var(--text-primary,var(--token-foreground))}.codex-linux-read-aloud-button[data-codex-linux-read-aloud-state=error]{color:var(--token-error-foreground,#c00);border-color:currentColor}";document.head.appendChild(e)}`, `installStyle();globalThis.${HELPER_MARKER}=click;globalThis.${SETUP_MARKER}=setup;})();`, @@ -177,18 +182,12 @@ function applyAssistantRenderPatch(source) { } function applySettingsPatch(source) { - if (source.includes(SETTINGS_KEY)) { - return source; - } - const keyNeedle = "warmStart:\"codex-linux-warm-start-enabled\""; - const keyReplacement = `warmStart:"codex-linux-warm-start-enabled",readAloud:${JSON.stringify(SETTINGS_KEY)}`; - const rowNeedle = '$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."})'; - const rowReplacement = `${rowNeedle},$.jsx(LinuxToggle,{settingKey:KEYS.readAloud,label:"Read aloud responses",description:"Show a Read aloud button on assistant responses.",defaultValue:!1})`; - if (!source.includes(keyNeedle) || !source.includes(rowNeedle)) { - warn("Could not find Linux settings toggle insertion point", "read aloud settings patch"); - return source; - } - return source.replace(keyNeedle, keyReplacement).replace(rowNeedle, rowReplacement); + return source + .replace(`,readAloud:${JSON.stringify(SETTINGS_KEY)}`, "") + .replace( + ',$.jsx(LinuxToggle,{settingKey:KEYS.readAloud,label:"Read aloud responses",description:"Show a Read aloud button on assistant responses.",defaultValue:!1})', + "", + ); } function generalSettingsReadAloudRowSource() { @@ -217,17 +216,10 @@ function replaceExistingGeneralSettingsReadAloudRow(source) { return `${source.slice(0, start)}${generalSettingsReadAloudBlockSource()}${source.slice(end)}`; } -function applyGeneralSettingsRowPlacement(source) { - if (source.includes(GENERAL_SETTINGS_CHILDREN_WITH_ROW)) { - return source; - } - if (source.includes(GENERAL_SETTINGS_CHILDREN_WITH_OLD_ROW)) { - return source.replace(GENERAL_SETTINGS_CHILDREN_WITH_OLD_ROW, GENERAL_SETTINGS_CHILDREN_WITH_ROW); - } - if (source.includes(GENERAL_SETTINGS_CHILDREN)) { - return source.replace(GENERAL_SETTINGS_CHILDREN, GENERAL_SETTINGS_CHILDREN_WITH_ROW); - } - return source; +function removeGeneralSettingsRowPlacement(source) { + return source + .replace(GENERAL_SETTINGS_CHILDREN_WITH_ROW, GENERAL_SETTINGS_CHILDREN) + .replace(GENERAL_SETTINGS_CHILDREN_WITH_OLD_ROW, GENERAL_SETTINGS_CHILDREN); } function applyGeneralSettingsPatch(source) { @@ -248,7 +240,7 @@ function applyGeneralSettingsPatch(source) { } else { patched = patched.replace(functionNeedle, `${generalSettingsReadAloudBlockSource()}${functionNeedle}`); } - return ensureReadAloudRuntime(applyGeneralSettingsExportPatch(applyGeneralSettingsRowPlacement(patched))); + return ensureReadAloudRuntime(applyGeneralSettingsExportPatch(removeGeneralSettingsRowPlacement(patched))); } function applyGeneralSettingsExportPatch(source) { @@ -403,24 +395,24 @@ function patchMatchingAssets(assetsDir, pattern, apply) { } function applySettingsAssetPatch(extractedDir) { + let matched = false; + let changed = 0; const keybindsAssetPath = path.join(extractedDir, "webview", "assets", "keybinds-settings-linux.js"); if (fs.existsSync(keybindsAssetPath)) { const source = fs.readFileSync(keybindsAssetPath, "utf8"); const patched = applySettingsPatch(source); - if (patched === source) { - return { matched: true, changed: 0 }; + matched = true; + if (patched !== source) { + fs.writeFileSync(keybindsAssetPath, patched, "utf8"); + changed += 1; } - fs.writeFileSync(keybindsAssetPath, patched, "utf8"); - return { matched: true, changed: 1 }; } const assetsDir = path.join(extractedDir, "webview", "assets"); if (!fs.existsSync(assetsDir)) { - return { matched: false, changed: 0, reason: "webview assets directory not found" }; + return { matched, changed, reason: "webview assets directory not found" }; } - let matched = false; - let changed = 0; const generalCandidates = fs .readdirSync(assetsDir) .filter((name) => /^general-settings-.*\.js$/.test(name)) diff --git a/linux-features/read-aloud/test.js b/linux-features/read-aloud/test.js index 06b709af..901d8152 100644 --- a/linux-features/read-aloud/test.js +++ b/linux-features/read-aloud/test.js @@ -37,7 +37,10 @@ test("main bundle patch adds a Linux read aloud handler", () => { assert.match(patched, /function codexLinuxReadAloudSpeak/); assert.match(patched, /function codexLinuxReadAloudConfig/); assert.match(patched, /function codexLinuxReadAloudSetup/); + assert.match(patched, /function codexLinuxReadAloudNativeFallbackEnabled/); + assert.match(patched, /function codexLinuxReadAloudSetupResult/); assert.match(patched, /codex-linux-read-aloud-kokoro-model/); + assert.match(patched, /codex-linux-read-aloud-kokoro-python/); assert.match(patched, /codex-linux-read-aloud-kokoro-speed/); assert.match(patched, /codex-linux-read-aloud-kokoro-voices/); assert.match(patched, /CODEX_LINUX_SETTINGS_FILE/); @@ -54,7 +57,9 @@ test("main bundle patch adds a Linux read aloud handler", () => { assert.match(patched, /huggingface\.co\/zijuncheng\/kokoro_model_v1\.0\/resolve\/main\/kokoro-v1\.0\.onnx/); assert.match(patched, /huggingface\.co\/zijuncheng\/kokoro_model_v1\.0\/resolve\/main\/voices-v1\.0\.bin/); assert.match(patched, /download too small/); + assert.match(patched, /User-Agent/); assert.doesNotMatch(patched, /readd-stdin/); + assert.doesNotMatch(patched, /\|\|\s*`female1`/); assert.match(patched, /spd-say/); assert.match(patched, /espeak-ng/); assert.doesNotThrow(() => new Function("require", "process", patched)); @@ -67,7 +72,7 @@ test("webview runtime appends only once", () => { assert.match(patched, /codex-message-from-view/); assert.match(patched, /__codexForwardedViaBridge/); assert.match(patched, /Starting voice/); - assert.match(patched, /kokoro-explicit-v4/); + assert.match(patched, /kokoro-explicit-v5/); assert.match(patched, /codexLinuxConversationIsSpeaking/); assert.match(patched, /codexLinuxConversationStopSpeaking/); assert.match(patched, /speechSynthesis\?\.cancel/); @@ -75,7 +80,7 @@ test("webview runtime appends only once", () => { assert.match(patched, /codexLinuxReadAloudSetup/); assert.match(patched, /action:"setup",mode/); assert.match(patched, /9e5/); - assert.match(applyIndexRuntimePatch("globalThis.codexLinuxReadAloudClick=()=>{};"), /kokoro-explicit-v4/); + assert.match(applyIndexRuntimePatch("globalThis.codexLinuxReadAloudClick=()=>{};"), /kokoro-explicit-v5/); assert.doesNotMatch(patched, /SpeechSynthesisUtterance/); assert.doesNotMatch(patched, /browser speech/); assert.doesNotMatch(patched, /no-voices/); @@ -146,6 +151,15 @@ test("main handler stores a chosen Kokoro model folder", async () => { fs.mkdirSync(modelDir, { recursive: true }); fs.writeFileSync(path.join(modelDir, "kokoro-v1.0.onnx"), ""); fs.writeFileSync(path.join(modelDir, "voices-v1.0.bin"), ""); + const resourcesPath = path.join(root, "resources"); + const runner = path.join(resourcesPath, "read-aloud", "kokoro-stdin"); + fs.mkdirSync(path.dirname(runner), { recursive: true }); + fs.writeFileSync(runner, ""); + fs.chmodSync(runner, 0o755); + const python = path.join(root, ".local", "share", "codex-desktop", "read-aloud", "kokoro-venv", "bin", "python"); + fs.mkdirSync(path.dirname(python), { recursive: true }); + fs.writeFileSync(python, ""); + fs.chmodSync(python, 0o755); const source = [ "let e=require(`node:child_process`),f=require(`node:fs`),p=require(`node:path`),o=require(`node:os`);", @@ -155,6 +169,62 @@ test("main handler stores a chosen Kokoro model folder", async () => { assert.doesNotMatch(patched, /\bo=o\.createWriteStream\b/); assert.doesNotMatch(patched, /let n=[^;]*,r=p\.join\(n,[^;]*,p=p\.join/); + const requireStub = (name) => { + if (name === "node:child_process") { + return { + spawnSync: (command, args) => ({ + status: command === "which" && args?.[0] === "aplay" ? 0 : 1, + }), + }; + } + if (name === "node:fs") return fs; + if (name === "node:path") return path; + if (name === "node:os") return { homedir: () => root }; + if (name === "electron") { + return { + dialog: { + showOpenDialog: async () => ({ canceled: false, filePaths: [modelDir] }), + }, + }; + } + return require(name); + }; + const processStub = { + platform: "linux", + env: { HOME: root, XDG_CONFIG_HOME: configHome }, + resourcesPath, + }; + const result = await new Function( + "require", + "process", + `${patched};return codexLinuxReadAloudHandle({action:"setup",mode:"choose-folder"});`, + )(requireStub, processStub); + + assert.equal(result.ok, true); + const settings = JSON.parse( + fs.readFileSync(path.join(configHome, "codex-desktop", "settings.json"), "utf8"), + ); + assert.equal(settings["codex-linux-read-aloud-kokoro-model"], path.join(modelDir, "kokoro-v1.0.onnx")); + assert.equal(settings["codex-linux-read-aloud-kokoro-voices"], path.join(modelDir, "voices-v1.0.bin")); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("main handler reports when a chosen Kokoro model folder is not speakable yet", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-read-aloud-main-")); + try { + const configHome = path.join(root, "config"); + const modelDir = path.join(root, "kokoro"); + fs.mkdirSync(modelDir, { recursive: true }); + fs.writeFileSync(path.join(modelDir, "kokoro-v1.0.onnx"), ""); + fs.writeFileSync(path.join(modelDir, "voices-v1.0.bin"), ""); + + const source = [ + "let e=require(`node:child_process`),f=require(`node:fs`),p=require(`node:path`),o=require(`node:os`);", + "var h={handlers:{\"set-vs-context\":async()=>{},\"native-desktop-apps\":async()=>({apps:[]})}};", + ].join(""); + const patched = twice(applyMainBundlePatch, source); const requireStub = (name) => { if (name === "node:child_process") { return { spawnSync: () => ({ status: 1 }) }; @@ -182,7 +252,9 @@ test("main handler stores a chosen Kokoro model folder", async () => { `${patched};return codexLinuxReadAloudHandle({action:"setup",mode:"choose-folder"});`, )(requireStub, processStub); - assert.equal(result.ok, true); + assert.equal(result.ok, false); + assert.equal(result.reason, "voice-unavailable"); + assert.deepEqual(result.config.kokoro.missing.sort(), ["aplay", "python", "runner"].sort()); const settings = JSON.parse( fs.readFileSync(path.join(configHome, "codex-desktop", "settings.json"), "utf8"), ); @@ -201,6 +273,15 @@ test("main handler honors Linux app-specific settings paths", async () => { fs.mkdirSync(modelDir, { recursive: true }); fs.writeFileSync(path.join(modelDir, "kokoro-v1.0.onnx"), ""); fs.writeFileSync(path.join(modelDir, "voices-v1.0.bin"), ""); + const resourcesPath = path.join(root, "resources"); + const runner = path.join(resourcesPath, "read-aloud", "kokoro-stdin"); + fs.mkdirSync(path.dirname(runner), { recursive: true }); + fs.writeFileSync(runner, ""); + fs.chmodSync(runner, 0o755); + const python = path.join(root, ".local", "share", "codex-desktop", "read-aloud", "kokoro-venv", "bin", "python"); + fs.mkdirSync(path.dirname(python), { recursive: true }); + fs.writeFileSync(python, ""); + fs.chmodSync(python, 0o755); const source = [ "let e=require(`node:child_process`),f=require(`node:fs`),p=require(`node:path`),o=require(`node:os`);", @@ -209,7 +290,11 @@ test("main handler honors Linux app-specific settings paths", async () => { const patched = twice(applyMainBundlePatch, source); const requireStub = (name) => { if (name === "node:child_process") { - return { spawnSync: () => ({ status: 1 }) }; + return { + spawnSync: (command, args) => ({ + status: command === "which" && args?.[0] === "aplay" ? 0 : 1, + }), + }; } if (name === "node:fs") return fs; if (name === "node:path") return path; @@ -226,7 +311,7 @@ test("main handler honors Linux app-specific settings paths", async () => { const processStub = { platform: "linux", env: { HOME: root, XDG_CONFIG_HOME: configHome, CODEX_LINUX_APP_ID: "codex-desktop-5" }, - resourcesPath: path.join(root, "resources"), + resourcesPath, }; const result = await new Function( "require", @@ -323,6 +408,169 @@ test("main handler reads and clamps stored Kokoro pace", async () => { } }); +test("main handler enables native fallback by default but allows explicit disable", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-read-aloud-main-")); + try { + const source = [ + "let e=require(`node:child_process`),f=require(`node:fs`),p=require(`node:path`),o=require(`node:os`);", + "var h={handlers:{\"set-vs-context\":async()=>{},\"native-desktop-apps\":async()=>({apps:[]})}};", + ].join(""); + const patched = twice(applyMainBundlePatch, source); + const requireStub = (name) => { + if (name === "node:child_process") { + return { spawnSync: () => ({ status: 1 }) }; + } + if (name === "node:fs") return fs; + if (name === "node:path") return path; + if (name === "node:os") return { homedir: () => root }; + return require(name); + }; + + const defaultConfig = await new Function( + "require", + "process", + `${patched};return codexLinuxReadAloudHandle({action:"config"});`, + )(requireStub, { + platform: "linux", + env: { HOME: root }, + resourcesPath: path.join(root, "resources"), + }); + const disabledConfig = await new Function( + "require", + "process", + `${patched};return codexLinuxReadAloudHandle({action:"config"});`, + )(requireStub, { + platform: "linux", + env: { HOME: root, CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK: "0" }, + resourcesPath: path.join(root, "resources"), + }); + + assert.equal(defaultConfig.nativeFallback, true); + assert.equal(disabledConfig.nativeFallback, false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("main handler passes the configured Kokoro Python to the runner", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-read-aloud-main-")); + try { + const resourcesPath = path.join(root, "resources"); + const runner = path.join(resourcesPath, "read-aloud", "kokoro-stdin"); + fs.mkdirSync(path.dirname(runner), { recursive: true }); + fs.writeFileSync(runner, ""); + fs.chmodSync(runner, 0o755); + const python = path.join(root, "venv", "bin", "python"); + fs.mkdirSync(path.dirname(python), { recursive: true }); + fs.writeFileSync(python, ""); + fs.chmodSync(python, 0o755); + const model = path.join(root, "kokoro", "kokoro-v1.0.onnx"); + const voices = path.join(root, "kokoro", "voices-v1.0.bin"); + fs.mkdirSync(path.dirname(model), { recursive: true }); + fs.writeFileSync(model, ""); + fs.writeFileSync(voices, ""); + + const source = [ + "let e=require(`node:child_process`),f=require(`node:fs`),p=require(`node:path`),o=require(`node:os`);", + "var h={handlers:{\"set-vs-context\":async()=>{},\"native-desktop-apps\":async()=>({apps:[]})}};", + ].join(""); + const patched = twice(applyMainBundlePatch, source); + const spawned = []; + const requireStub = (name) => { + if (name === "node:child_process") { + return { + spawnSync: (command, args) => ({ + status: command === "which" && args?.[0] === "aplay" ? 0 : 1, + }), + spawn: (command, args, options) => { + spawned.push({ command, args, options }); + return { + on: () => {}, + unref: () => {}, + stdin: { end: () => {} }, + }; + }, + }; + } + if (name === "node:fs") return fs; + if (name === "node:path") return path; + if (name === "node:os") return { homedir: () => root }; + return require(name); + }; + const result = await new Function( + "require", + "process", + `${patched};return codexLinuxReadAloudHandle({action:"speak",source:"button",text:"hello"});`, + )(requireStub, { + platform: "linux", + env: { + HOME: root, + CODEX_LINUX_READ_ALOUD_ENABLED: "1", + CODEX_LINUX_READ_ALOUD_KOKORO_PYTHON: python, + CODEX_LINUX_READ_ALOUD_KOKORO_MODEL: model, + CODEX_LINUX_READ_ALOUD_KOKORO_VOICES: voices, + }, + resourcesPath, + }); + + assert.equal(result.spoken, true); + assert.equal(result.engine, "kokoro"); + assert.equal(spawned[0]?.command, runner); + assert.equal(spawned[0]?.options?.env?.CODEX_LINUX_READ_ALOUD_KOKORO_PYTHON, python); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("main handler falls back to native speech without forcing spd-say voice type", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-read-aloud-main-")); + try { + const source = [ + "let e=require(`node:child_process`),f=require(`node:fs`),p=require(`node:path`),o=require(`node:os`);", + "var h={handlers:{\"set-vs-context\":async()=>{},\"native-desktop-apps\":async()=>({apps:[]})}};", + ].join(""); + const patched = twice(applyMainBundlePatch, source); + const spawned = []; + const requireStub = (name) => { + if (name === "node:child_process") { + return { + spawnSync: (command, args) => ({ + status: command === "which" && args?.[0] === "spd-say" ? 0 : 1, + }), + spawn: (command, args, options) => { + spawned.push({ command, args, options }); + return { + on: () => {}, + unref: () => {}, + }; + }, + }; + } + if (name === "node:fs") return fs; + if (name === "node:path") return path; + if (name === "node:os") return { homedir: () => root }; + return require(name); + }; + const result = await new Function( + "require", + "process", + `${patched};return codexLinuxReadAloudHandle({action:"speak",source:"button",text:"hello"});`, + )(requireStub, { + platform: "linux", + env: { HOME: root, CODEX_LINUX_READ_ALOUD_ENABLED: "1" }, + resourcesPath: path.join(root, "resources"), + }); + + assert.equal(result.spoken, true); + assert.equal(result.engine, "spd-say"); + const speakCall = spawned.find((entry) => entry.command === "spd-say" && entry.args.includes("--")); + assert.ok(speakCall); + assert.equal(speakCall.args.includes("-t"), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("main handler exposes Hugging Face Kokoro download defaults", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-read-aloud-main-")); try { @@ -373,6 +621,7 @@ test("main handler downloads setup files atomically", async () => { ].join(""); const patched = twice(applyMainBundlePatch, source); const target = path.join(root, "kokoro", "sample.bin"); + let observedHeaders = null; const requireStub = (name) => { if (name === "node:child_process") { return { spawnSync: () => ({ status: 1 }) }; @@ -382,10 +631,11 @@ test("main handler downloads setup files atomically", async () => { if (name === "node:os") return { homedir: () => root }; if (name === "node:https") { return { - get: (_url, callback) => { + get: (_url, options, callback) => { const { EventEmitter } = require("node:events"); const { PassThrough } = require("node:stream"); const request = new EventEmitter(); + observedHeaders = options?.headers; process.nextTick(() => { const response = new PassThrough(); response.statusCode = 200; @@ -412,6 +662,7 @@ test("main handler downloads setup files atomically", async () => { assert.equal(fs.readFileSync(target, "utf8"), "downloaded voice bytes"); assert.equal(fs.existsSync(`${target}.part`), false); + assert.equal(observedHeaders?.["User-Agent"], "codex-desktop-read-aloud"); } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -437,16 +688,24 @@ test("assistant render patch preserves the current JSX runtime alias", () => { assert.match(patched, /globalThis\.codexLinuxReadAloudClick\?\.\(n,p,o,e\.currentTarget\)/); }); -test("settings patch adds disabled-by-default toggle", () => { +test("settings patch does not add the legacy normal settings toggle", () => { const source = 'KEYS={promptWindow:"codex-linux-prompt-window-enabled",systemTray:"codex-linux-system-tray-enabled",warmStart:"codex-linux-warm-start-enabled"};$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."})'; const patched = twice(applySettingsPatch, source); - assert.match(patched, /readAloud:"codex-linux-read-aloud-enabled"/); - assert.match(patched, /label:"Read aloud responses"/); - assert.match(patched, /Show a Read aloud button/); - assert.match(patched, /defaultValue:!1/); + assert.equal(patched, source); + assert.doesNotMatch(patched, /readAloud:"codex-linux-read-aloud-enabled"/); + assert.doesNotMatch(patched, /label:"Read aloud responses"/); }); -test("general settings patch adds current upstream read aloud toggle", () => { +test("settings patch removes an older legacy normal settings toggle", () => { + const source = 'KEYS={promptWindow:"codex-linux-prompt-window-enabled",systemTray:"codex-linux-system-tray-enabled",warmStart:"codex-linux-warm-start-enabled",readAloud:"codex-linux-read-aloud-enabled"};$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."}),$.jsx(LinuxToggle,{settingKey:KEYS.readAloud,label:"Read aloud responses",description:"Show a Read aloud button on assistant responses.",defaultValue:!1})'; + const patched = twice(applySettingsPatch, source); + assert.doesNotMatch(patched, /readAloud:"codex-linux-read-aloud-enabled"/); + assert.doesNotMatch(patched, /label:"Read aloud responses"/); + assert.match(patched, /KEYS=\{promptWindow/); + assert.match(patched, /Warm start/); +}); + +test("general settings patch exports read aloud page without rendering it in General", () => { const source = "function Gn(){return (0,$.jsxs)(ht,{children:[S,C,w,T,D,O,k,A,j,M,N,P,L]})}"; const patched = twice(applyGeneralSettingsPatch, source); assert.match(patched, /function codexLinuxReadAloudSettingsRow/); @@ -467,16 +726,17 @@ test("general settings patch adds current upstream read aloud toggle", () => { assert.match(patched, /min:\.7/); assert.match(patched, /max:1\.4/); assert.match(patched, /codexLinuxReadAloudSetup/); - assert.match(patched, /kokoro-explicit-v4/); + assert.match(patched, /kokoro-explicit-v5/); assert.match(patched, /globalThis\.codexLinuxReadAloudSetup=setup/); assert.doesNotThrow(() => new Function("$", "w", "C", "N", "L", "F", "P", "J", "q", patched)); - assert.match( + assert.doesNotMatch( patched, /children:\[S,C,w,T,\(0,\$\.jsx\)\(codexLinuxReadAloudSettingsRow,\{\}\),D,O,k,A,j,M,N,P,L\]/, ); + assert.match(patched, /children:\[S,C,w,T,D,O,k,A,j,M,N,P,L\]/); }); -test("general settings patch upgrades and repositions an older injected row", () => { +test("general settings patch upgrades and removes an older injected General row", () => { const source = [ "function codexLinuxReadAloudSettingsRow(){let e=(0,Q.c)(11),t=w(C),n=N(),{data:r,isLoading:i}=L(\"codex-linux-read-aloud-enabled\"),a=r===!0,o,s;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,$.jsx)(F,{id:`settings.general.readAloud.label`,defaultMessage:`Read aloud responses`,description:`Label for Linux read aloud setting`}),s=(0,$.jsx)(F,{id:`settings.general.readAloud.description`,defaultMessage:`Show a read aloud button under assistant responses`,description:`Description for Linux read aloud setting`}),e[0]=o,e[1]=s):(o=e[0],s=e[1]);let c;e[2]===t?c=e[3]:(c=e=>{P(t,\"codex-linux-read-aloud-enabled\",e)},e[2]=t,e[3]=c);let l;e[4]===n?l=e[5]:(l=n.formatMessage({id:`settings.general.readAloud.label`,defaultMessage:`Read aloud responses`,description:`Label for Linux read aloud setting`}),e[4]=n,e[5]=l);let u;return e[6]!==i||e[7]!==a||e[8]!==c||e[9]!==l?(u=(0,$.jsx)(J,{label:o,description:s,control:(0,$.jsx)(q,{checked:a,disabled:i,onChange:c,ariaLabel:l})}),e[6]=i,e[7]=a,e[8]=c,e[9]=l,e[10]=u):u=e[10],u}", "function Gn(){return (0,$.jsxs)(ht,{children:[S,C,w,T,D,O,k,(0,$.jsx)(codexLinuxReadAloudSettingsRow,{}),A,j,M,N,P,L]})}", @@ -488,7 +748,7 @@ test("general settings patch upgrades and repositions an older injected row", () assert.match(patched, /settings\.general\.readAloud\.help/); assert.match(patched, /Speech pace/); assert.match(patched, /function codexLinuxReadAloudSettingsPage/); - assert.match( + assert.doesNotMatch( patched, /children:\[S,C,w,T,\(0,\$\.jsx\)\(codexLinuxReadAloudSettingsRow,\{\}\),D,O,k,A,j,M,N,P,L\]/, ); @@ -496,6 +756,7 @@ test("general settings patch upgrades and repositions an older injected row", () patched, /children:\[S,C,w,T,D,O,k,\(0,\$\.jsx\)\(codexLinuxReadAloudSettingsRow,\{\}\),A,j,M,N,P,L\]/, ); + assert.match(patched, /children:\[S,C,w,T,D,O,k,A,j,M,N,P,L\]/); assert.equal((patched.match(/function codexLinuxReadAloudSettingsRow/g) ?? []).length, 1); }); @@ -556,7 +817,7 @@ test("app route patch wires read aloud settings to the generated page export", ( assert.match(patched, /"general-settings":\(0,Q\.lazy\)/); }); -test("settings asset patch updates generated keybinds settings file", () => { +test("settings asset patch leaves current keybinds settings file alone", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-read-aloud-settings-")); try { const assets = path.join(root, "webview", "assets"); @@ -566,10 +827,31 @@ test("settings asset patch updates generated keybinds settings file", () => { asset, 'KEYS={promptWindow:"codex-linux-prompt-window-enabled",systemTray:"codex-linux-system-tray-enabled",warmStart:"codex-linux-warm-start-enabled"};$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."})', ); + assert.deepEqual(applySettingsAssetPatch(root), { matched: true, changed: 0 }); + const patched = fs.readFileSync(asset, "utf8"); + assert.doesNotMatch(patched, /readAloud:"codex-linux-read-aloud-enabled"/); + assert.doesNotMatch(patched, /label:"Read aloud responses"/); + assert.deepEqual(applySettingsAssetPatch(root), { matched: true, changed: 0 }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("settings asset patch removes an older generated keybinds read aloud toggle", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-read-aloud-settings-")); + try { + const assets = path.join(root, "webview", "assets"); + fs.mkdirSync(assets, { recursive: true }); + const asset = path.join(assets, "keybinds-settings-linux.js"); + fs.writeFileSync( + asset, + 'KEYS={promptWindow:"codex-linux-prompt-window-enabled",systemTray:"codex-linux-system-tray-enabled",warmStart:"codex-linux-warm-start-enabled",readAloud:"codex-linux-read-aloud-enabled"};$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."}),$.jsx(LinuxToggle,{settingKey:KEYS.readAloud,label:"Read aloud responses",description:"Show a Read aloud button on assistant responses.",defaultValue:!1})', + ); assert.deepEqual(applySettingsAssetPatch(root), { matched: true, changed: 1 }); const patched = fs.readFileSync(asset, "utf8"); - assert.match(patched, /readAloud:"codex-linux-read-aloud-enabled"/); - assert.match(patched, /label:"Read aloud responses"/); + assert.doesNotMatch(patched, /readAloud:"codex-linux-read-aloud-enabled"/); + assert.doesNotMatch(patched, /label:"Read aloud responses"/); + assert.match(patched, /Warm start/); assert.deepEqual(applySettingsAssetPatch(root), { matched: true, changed: 0 }); } finally { fs.rmSync(root, { recursive: true, force: true }); @@ -593,11 +875,12 @@ test("settings asset patch upgrades older general settings bundle", () => { const patched = fs.readFileSync(asset, "utf8"); assert.match(patched, /codex-linux-read-aloud-kokoro-speed/); assert.match(patched, /Choose folder/); - assert.match(patched, /kokoro-explicit-v4/); - assert.match( + assert.match(patched, /kokoro-explicit-v5/); + assert.doesNotMatch( patched, /children:\[S,C,w,T,\(0,\$\.jsx\)\(codexLinuxReadAloudSettingsRow,\{\}\),D,O,k,A,j,M,N,P,L\]/, ); + assert.match(patched, /children:\[S,C,w,T,D,O,k,A,j,M,N,P,L\]/); assert.deepEqual(applySettingsAssetPatch(root), { matched: true, changed: 0 }); } finally { fs.rmSync(root, { recursive: true, force: true }); @@ -619,6 +902,10 @@ test("settings asset patch updates current general settings bundle", () => { assert.match(patched, /codex-linux-read-aloud-enabled/); assert.match(patched, /codexLinuxReadAloudSettingsRow/); assert.match(patched, /globalThis\.codexLinuxReadAloudSetup=setup/); + assert.doesNotMatch( + patched, + /children:\[S,C,w,T,\(0,\$\.jsx\)\(codexLinuxReadAloudSettingsRow,\{\}\),D,O,k,A,j,M,N,P,L\]/, + ); assert.deepEqual(applySettingsAssetPatch(root), { matched: true, changed: 0 }); } finally { fs.rmSync(root, { recursive: true, force: true }); @@ -630,6 +917,10 @@ test("settings asset patch creates a first-class read aloud settings section", ( try { const assets = path.join(root, "webview", "assets"); fs.mkdirSync(assets, { recursive: true }); + fs.writeFileSync( + path.join(assets, "keybinds-settings-linux.js"), + 'KEYS={promptWindow:"codex-linux-prompt-window-enabled",systemTray:"codex-linux-system-tray-enabled",warmStart:"codex-linux-warm-start-enabled"};$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."})', + ); fs.writeFileSync( path.join(assets, "general-settings-inner.js"), [ diff --git a/read-aloud-linux/src/main.rs b/read-aloud-linux/src/main.rs index f779e0fd..703acc17 100644 --- a/read-aloud-linux/src/main.rs +++ b/read-aloud-linux/src/main.rs @@ -261,19 +261,10 @@ impl ReadAloudLinux { return Ok(BackendCommand { name: "spd-say".to_string(), command: "spd-say".to_string(), - args: vec![ - "-r".to_string(), - env_trimmed("CODEX_LINUX_READ_ALOUD_RATE").unwrap_or_else(|| "-10".to_string()), - "-t".to_string(), - env_trimmed("CODEX_LINUX_READ_ALOUD_VOICE_TYPE") - .unwrap_or_else(|| "female1".to_string()), - "-l".to_string(), - "en".to_string(), - "--".to_string(), - ], + args: spd_say_args(), envs: Vec::new(), stdin: false, - note: "Using native spd-say fallback because CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK=1.".to_string(), + note: "Using native spd-say fallback.".to_string(), }); } if command_exists("espeak-ng") { @@ -282,14 +273,16 @@ impl ReadAloudLinux { command: "espeak-ng".to_string(), args: vec![ "-v".to_string(), - env_trimmed("CODEX_LINUX_READ_ALOUD_VOICE").unwrap_or_else(|| "en-us".to_string()), + env_trimmed("CODEX_LINUX_READ_ALOUD_VOICE") + .unwrap_or_else(|| "en-us".to_string()), "-s".to_string(), - env_trimmed("CODEX_LINUX_READ_ALOUD_ESPEAK_RATE").unwrap_or_else(|| "165".to_string()), + env_trimmed("CODEX_LINUX_READ_ALOUD_ESPEAK_RATE") + .unwrap_or_else(|| "165".to_string()), "--".to_string(), ], envs: Vec::new(), stdin: false, - note: "Using native espeak-ng fallback because CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK=1.".to_string(), + note: "Using native espeak-ng fallback.".to_string(), }); } } @@ -774,10 +767,29 @@ fn env_trimmed(name: &str) -> Option { .and_then(|value| non_empty(&value).map(str::to_string)) } +fn spd_say_args() -> Vec { + let mut args = vec![ + "-r".to_string(), + env_trimmed("CODEX_LINUX_READ_ALOUD_RATE").unwrap_or_else(|| "-10".to_string()), + ]; + if let Some(voice_type) = env_trimmed("CODEX_LINUX_READ_ALOUD_VOICE_TYPE") { + args.push("-t".to_string()); + args.push(voice_type); + } + if let Some(voice) = env_trimmed("CODEX_LINUX_READ_ALOUD_VOICE") { + args.push("-y".to_string()); + args.push(voice); + } + args.extend(["-l".to_string(), "en".to_string(), "--".to_string()]); + args +} + fn native_fallback_enabled() -> bool { env::var("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK") - .map(|value| value == "1") - .unwrap_or(false) + .ok() + .and_then(|value| non_empty(&value).map(|value| value.to_ascii_lowercase())) + .map(|value| !matches!(value.as_str(), "0" | "false" | "off" | "no")) + .unwrap_or(true) } fn audio_session_envs() -> Vec<(String, String)> { @@ -900,6 +912,65 @@ mod tests { restore_env("XDG_RUNTIME_DIR", previous_runtime_dir); } + #[test] + fn native_fallback_defaults_to_enabled() { + let _guard = ENV_LOCK.lock().unwrap(); + let previous = env::var_os("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK"); + env::remove_var("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK"); + + assert!(native_fallback_enabled()); + restore_env("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK", previous); + } + + #[test] + fn native_fallback_can_be_disabled_explicitly() { + let _guard = ENV_LOCK.lock().unwrap(); + let previous = env::var_os("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK"); + + for value in ["0", "false", "off", "no"] { + env::set_var("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK", value); + assert!( + !native_fallback_enabled(), + "{value} should disable fallback" + ); + } + env::set_var("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK", "1"); + assert!(native_fallback_enabled()); + restore_env("CODEX_LINUX_READ_ALOUD_NATIVE_FALLBACK", previous); + } + + #[test] + fn spd_say_args_do_not_force_voice_type_by_default() { + let _guard = ENV_LOCK.lock().unwrap(); + let previous_type = env::var_os("CODEX_LINUX_READ_ALOUD_VOICE_TYPE"); + let previous_voice = env::var_os("CODEX_LINUX_READ_ALOUD_VOICE"); + env::remove_var("CODEX_LINUX_READ_ALOUD_VOICE_TYPE"); + env::remove_var("CODEX_LINUX_READ_ALOUD_VOICE"); + + let args = spd_say_args(); + + assert!(!args + .windows(2) + .any(|pair| pair[0] == "-t" && pair[1] == "female1")); + assert!(!args.iter().any(|arg| arg == "-t")); + restore_env("CODEX_LINUX_READ_ALOUD_VOICE_TYPE", previous_type); + restore_env("CODEX_LINUX_READ_ALOUD_VOICE", previous_voice); + } + + #[test] + fn spd_say_args_honor_explicit_voice_type() { + let _guard = ENV_LOCK.lock().unwrap(); + let previous_type = env::var_os("CODEX_LINUX_READ_ALOUD_VOICE_TYPE"); + env::set_var("CODEX_LINUX_READ_ALOUD_VOICE_TYPE", "male1"); + + let args = spd_say_args(); + + assert!(args + .windows(2) + .any(|pair| pair[0] == "-t" && pair[1] == "male1")); + restore_env("CODEX_LINUX_READ_ALOUD_VOICE_TYPE", previous_type); + } + fn restore_env(name: &str, value: Option) { match value { Some(value) => env::set_var(name, value),