{"id":1887,"date":"2026-01-29T04:47:46","date_gmt":"2026-01-29T04:47:46","guid":{"rendered":"https:\/\/phamho.com\/book\/?p=1887"},"modified":"2026-01-29T14:18:27","modified_gmt":"2026-01-29T14:18:27","slug":"text-to-speech","status":"publish","type":"post","link":"https:\/\/phamho.com\/book\/text-to-speech\/","title":{"rendered":"Text-to-speech"},"content":{"rendered":"\n<!-- \u2705 WordPress-safe: paste this WHOLE block into a Custom HTML block -->\n<div id=\"phamho-tts\">\n  <div class=\"wrap\">\n    <header class=\"hdr\">\n      <div>\n        <h1>ICTE Robots: Text-to-Speech<\/h1>\n        <div class=\"sub\">Type or paste text, pick a voice, then Speak \/ Pause \/ Resume \/ Stop. Works with your browser\u2019s built-in voices.<\/div>\n      <\/div>\n      <div class=\"pill\" id=\"phamhoSupportPill\">Checking support\u2026<\/div>\n    <\/header>\n\n    <div class=\"grid\">\n      <!-- Left: Text -->\n      <section class=\"card\" aria-label=\"Text input\">\n        <h2>Text<\/h2>\n        <div class=\"content\">\n          <label for=\"phamhoText\">Your text<\/label>\n          <textarea id=\"phamhoText\" spellcheck=\"true\" placeholder=\"Paste your text here\u2026&#10;&#10;Tip: Add punctuation for better prosody.\"><\/textarea>\n\n          <div class=\"row\">\n            <div>\n              <label for=\"phamhoPreset\">Quick presets<\/label>\n              <select id=\"phamhoPreset\">\n                <option value=\"\">\u2014 Choose a sample \u2014<\/option>\n                <option value=\"lesson\">Classroom instruction<\/option>\n                <option value=\"news\">News style paragraph<\/option>\n                <option value=\"dialogue\">Short dialogue<\/option>\n              <\/select>\n            <\/div>\n            <div>\n              <label for=\"phamhoChunkSize\">Chunk length (chars)<\/label>\n              <select id=\"phamhoChunkSize\">\n                <option value=\"220\">220 (safer)<\/option>\n                <option value=\"350\" selected>350 (balanced)<\/option>\n                <option value=\"600\">600 (longer)<\/option>\n                <option value=\"0\">No chunking (may fail on long text)<\/option>\n              <\/select>\n            <\/div>\n          <\/div>\n\n          <div class=\"btns\">\n            <button class=\"primary\" id=\"phamhoBtnSpeak\">Speak<\/button>\n            <button id=\"phamhoBtnPause\" disabled>Pause<\/button>\n            <button id=\"phamhoBtnResume\" disabled>Resume<\/button>\n            <button class=\"danger\" id=\"phamhoBtnStop\" disabled>Stop<\/button>\n            <button class=\"good\" id=\"phamhoBtnClear\">Clear<\/button>\n          <\/div>\n\n          <div class=\"status\" role=\"status\" aria-live=\"polite\">\n            <div>State: <span class=\"mono\" id=\"phamhoState\">idle<\/span><\/div>\n            <div>Chars: <span class=\"mono\" id=\"phamhoChars\">0<\/span><\/div>\n          <\/div>\n        <\/div>\n      <\/section>\n\n      <!-- Right: Controls -->\n      <section class=\"card\" aria-label=\"Voice and settings\">\n        <h2>Voice &#038; Settings<\/h2>\n        <div class=\"content\">\n          <label for=\"phamhoVoice\">Voice<\/label>\n          <select id=\"phamhoVoice\"><\/select>\n          <div class=\"small\" id=\"phamhoVoiceHint\"><\/div>\n\n          <div class=\"sliders\" aria-label=\"Speech controls\">\n            <div class=\"slider\">\n              <div class=\"sliderTop\">\n                <span class=\"k\">Rate<\/span>\n                <span class=\"v mono\" id=\"phamhoRateVal\">1.00<\/span>\n              <\/div>\n              <input id=\"phamhoRate\" type=\"range\" min=\"0.6\" max=\"1.4\" step=\"0.05\" value=\"1.0\" \/>\n            <\/div>\n\n            <div class=\"slider\">\n              <div class=\"sliderTop\">\n                <span class=\"k\">Pitch<\/span>\n                <span class=\"v mono\" id=\"phamhoPitchVal\">1.00<\/span>\n              <\/div>\n              <input id=\"phamhoPitch\" type=\"range\" min=\"0.6\" max=\"1.4\" step=\"0.05\" value=\"1.0\" \/>\n            <\/div>\n\n            <div class=\"slider\">\n              <div class=\"sliderTop\">\n                <span class=\"k\">Volume<\/span>\n                <span class=\"v mono\" id=\"phamhoVolVal\">1.00<\/span>\n              <\/div>\n              <input id=\"phamhoVolume\" type=\"range\" min=\"0\" max=\"1\" step=\"0.05\" value=\"1.0\" \/>\n            <\/div>\n          <\/div>\n\n          <label for=\"phamhoFilter\">Voice filter (optional)<\/label>\n          <select id=\"phamhoFilter\">\n            <option value=\"all\" selected>All voices<\/option>\n            <option value=\"en\">English voices only<\/option>\n            <option value=\"google_uk_us\">Google US\/UK only (if available)<\/option>\n          <\/select>\n\n          <div class=\"note\" style=\"margin-top:12px\">\n            <b>Optional (Server MP3):<\/b><br\/>\n            Paste a server endpoint that returns MP3 bytes (<span class=\"mono\">Content-Type: audio\/mpeg<\/span>).  \n            <b>Do NOT paste a normal webpage URL<\/b> (it will download HTML\/JSON, not audio).\n            <div style=\"margin-top:8px\">\n              <label for=\"phamhoServerUrl\">Server TTS URL (returns audio\/mp3)<\/label>\n              <input id=\"phamhoServerUrl\" type=\"text\" placeholder=\"Example: https:\/\/phamho.com\/wp-json\/icte-tts\/v1\/mp3\" \/>\n              <div class=\"btns\" style=\"margin-top:10px\">\n                <button id=\"phamhoBtnServerTTS\">Generate (Server)<\/button>\n              <\/div>\n\n              <div class=\"audioBox\" id=\"phamhoAudioBox\">\n                <audio id=\"phamhoAudio\" controls><\/audio>\n\n                <div class=\"dlRow\">\n                  <a id=\"phamhoBtnDownloadMp3\" href=\"#\" download=\"tts.mp3\" aria-disabled=\"true\">Download MP3<\/a>\n                  <button id=\"phamhoBtnForgetAudio\" type=\"button\">Forget audio<\/button>\n                <\/div>\n\n                <div class=\"small\" style=\"margin-top:6px;\">\n                  Download uses an in-memory Blob URL (no MP3 is saved to your webhost by this page).\n                <\/div>\n              <\/div>\n\n              <div class=\"small\">Important: never put your API key in this HTML\/JS.<\/div>\n            <\/div>\n          <\/div>\n\n          <div id=\"phamhoServerTip\" class=\"small\" style=\"margin-top:10px;\">\n            Tip: If you don\u2019t have an MP3 endpoint yet, use only the browser \u201cSpeak\u201d feature above (it can\u2019t export MP3).\n          <\/div>\n        <\/div>\n      <\/section>\n    <\/div>\n  <\/div>\n\n  <style>\n    \/* \u2705 Scoped, high-contrast, cleaned (removed global :root patch) *\/\n    #phamho-tts{\n      --bg:#07101f;\n      --text:#f8fafc;\n      --muted:#cbd5e1;\n      --border:#2a3a58;\n      --shadow: 0 12px 30px rgba(0,0,0,.45);\n      --radius:16px;\n\n      --btnBorder:#35507b;\n\n      --primaryBg:#1d4ed8;\n      --primaryBorder:#3b82f6;\n\n      --goodBg:#16a34a;\n      --goodBorder:#22c55e;\n\n      --dangerBg:#dc2626;\n      --dangerBorder:#ef4444;\n\n      color:var(--text);\n      padding:28px;\n      border-radius:18px;\n      background:\n        radial-gradient(900px 500px at 20% 0%, rgba(59,130,246,.22), transparent 60%),\n        radial-gradient(900px 500px at 80% 10%, rgba(34,197,94,.18), transparent 60%),\n        var(--bg);\n      font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;\n    }\n    #phamho-tts *{box-sizing:border-box}\n    #phamho-tts .wrap{max-width:1050px; width:100%; margin:0 auto;}\n    #phamho-tts .hdr{display:flex; gap:14px; align-items:flex-start; justify-content:space-between; margin-bottom:16px;}\n    #phamho-tts h1{font-size:28px; margin:0; letter-spacing:.2px; color:var(--text)}\n    #phamho-tts .sub{color:var(--muted); font-size:14px; margin-top:8px; line-height:1.45}\n    #phamho-tts .pill{\n      display:inline-flex; gap:8px; align-items:center;\n      padding:10px 12px; border:1px solid rgba(34,197,94,.55);\n      background: rgba(34,197,94,.12);\n      border-radius:999px; color:#dcfce7; font-size:12px; user-select:none;\n      white-space:nowrap;\n    }\n\n    #phamho-tts .grid{display:grid; grid-template-columns: 1.15fr .85fr; gap:14px;}\n    @media (max-width: 900px){ #phamho-tts .grid{grid-template-columns:1fr} }\n\n    #phamho-tts .card{\n      background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));\n      border: 1px solid var(--border);\n      border-radius: var(--radius);\n      box-shadow: var(--shadow);\n      overflow:hidden;\n    }\n    #phamho-tts .card h2{\n      font-size:15px; margin:0; padding:14px 16px;\n      border-bottom:1px solid rgba(255,255,255,.08);\n      color:#e6f0ff; letter-spacing:.2px;\n      background: linear-gradient(90deg, rgba(59,130,246,.18), rgba(34,197,94,.10));\n    }\n    #phamho-tts .content{padding:14px 16px;}\n\n    #phamho-tts label{display:block; font-size:12px; color:#e2e8f0; margin:10px 0 6px; font-weight:600;}\n    #phamho-tts textarea{\n      width:100%;\n      min-height:240px;\n      resize: vertical;\n      border-radius: 12px;\n      border: 1px solid rgba(148,163,184,.35);\n      background: rgba(2,6,23,.70);\n      color: var(--text);\n      padding: 12px 12px;\n      outline:none;\n      line-height:1.5;\n      font-size:15px;\n    }\n    #phamho-tts textarea::placeholder{color: rgba(226,232,240,.65)}\n    #phamho-tts textarea:focus{\n      border-color: rgba(59,130,246,.85);\n      box-shadow: 0 0 0 3px rgba(59,130,246,.20);\n    }\n\n    #phamho-tts .row{display:grid; grid-template-columns: 1fr 1fr; gap:10px; margin-top:12px;}\n    @media (max-width: 520px){ #phamho-tts .row{grid-template-columns:1fr} }\n\n    #phamho-tts select, #phamho-tts input[type=\"text\"]{\n      width:100%;\n      border-radius: 12px;\n      border: 1px solid rgba(148,163,184,.35);\n      background: rgba(2,6,23,.70);\n      color: var(--text);\n      padding: 10px 10px;\n      outline:none;\n      font-size:14px;\n    }\n    #phamho-tts select:focus, #phamho-tts input[type=\"text\"]:focus{\n      border-color: rgba(59,130,246,.85);\n      box-shadow: 0 0 0 3px rgba(59,130,246,.20);\n    }\n\n    #phamho-tts .sliders{display:grid; grid-template-columns:1fr; gap:10px; margin-top:8px;}\n    #phamho-tts .slider{\n      border: 1px solid rgba(255,255,255,.10);\n      border-radius: 12px;\n      padding: 10px 10px;\n      background: rgba(2,6,23,.40);\n    }\n    #phamho-tts .sliderTop{display:flex; align-items:center; justify-content:space-between; gap:10px;}\n    #phamho-tts .k{font-size:12px; color:#e2e8f0}\n    #phamho-tts .v{font-size:12px; color:#e2e8f0}\n    #phamho-tts input[type=\"range\"]{accent-color: #60a5fa;}\n\n    #phamho-tts .btns{display:flex; flex-wrap:wrap; gap:10px; margin-top:12px;}\n    #phamho-tts button{\n      border:1px solid var(--btnBorder);\n      background: rgba(15,23,42,.70);\n      color: var(--text);\n      padding: 10px 14px;\n      border-radius: 12px;\n      cursor:pointer;\n      font-size:14px;\n      font-weight:700;\n      transition: transform .05s ease, background .15s ease, border-color .15s ease, opacity .15s ease;\n    }\n    #phamho-tts button:hover{background: rgba(15,23,42,.88);}\n    #phamho-tts button:active{transform: translateY(1px);}\n    #phamho-tts button:disabled{opacity:.40; cursor:not-allowed;}\n\n    #phamho-tts .primary{background: rgba(29,78,216,.92); border-color: rgba(59,130,246,.95);}\n    #phamho-tts .primary:hover{background: rgba(29,78,216,1);}\n\n    #phamho-tts .good{background: rgba(22,163,74,.88); border-color: rgba(34,197,94,.95);}\n    #phamho-tts .good:hover{background: rgba(22,163,74,1);}\n\n    #phamho-tts .danger{background: rgba(220,38,38,.90); border-color: rgba(239,68,68,.95);}\n    #phamho-tts .danger:hover{background: rgba(220,38,38,1);}\n\n    #phamho-tts .status{\n      margin-top:12px;\n      font-size:12px;\n      color:#e2e8f0;\n      line-height:1.4;\n      border-top:1px solid rgba(255,255,255,.10);\n      padding-top:10px;\n      display:flex;\n      justify-content:space-between;\n      gap:10px;\n      flex-wrap:wrap;\n    }\n    #phamho-tts .mono{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", monospace;}\n    #phamho-tts .note{\n      font-size:12px;\n      color:#e2e8f0;\n      line-height:1.55;\n      padding: 10px 12px;\n      border:1px dashed rgba(255,255,255,.18);\n      border-radius: 12px;\n      background: rgba(2,6,23,.40);\n    }\n    #phamho-tts .audioBox{margin-top:12px; display:none;}\n    #phamho-tts .audioBox.show{display:block;}\n    #phamho-tts audio{width:100%;}\n    #phamho-tts .small{font-size:12px; color:#e2e8f0; opacity:.88;}\n\n    #phamho-tts .dlRow{margin-top:10px; display:flex; gap:10px; flex-wrap:wrap;}\n    #phamho-tts #phamhoBtnDownloadMp3{\n      display:inline-flex; align-items:center; gap:8px;\n      padding:10px 14px; border-radius:12px;\n      border:1px solid rgba(34,197,94,.95);\n      background: rgba(22,163,74,.88);\n      color:#ffffff; text-decoration:none; font-size:14px; font-weight:800;\n      opacity:.45; pointer-events:none;\n    }\n    #phamho-tts #phamhoBtnDownloadMp3[aria-disabled=\"false\"]{opacity:1; pointer-events:auto;}\n    #phamho-tts #phamhoBtnForgetAudio{\n      border:1px solid rgba(239,68,68,.95);\n      background: rgba(220,38,38,.90);\n      color:#ffffff;\n      padding:10px 14px;\n      border-radius:12px;\n      cursor:pointer;\n      font-size:14px;\n      font-weight:800;\n    }\n  <\/style>\n\n  <script>\n  (function(){\n    const $ = (id) => document.getElementById(id);\n\n    \/\/ Browser TTS\n    const supportPill = $(\"phamhoSupportPill\");\n    const textEl = $(\"phamhoText\");\n    const charsEl = $(\"phamhoChars\");\n    const stateEl = $(\"phamhoState\");\n\n    const voiceEl = $(\"phamhoVoice\");\n    const filterEl = $(\"phamhoFilter\");\n    const voiceHint = $(\"phamhoVoiceHint\");\n\n    const rateEl = $(\"phamhoRate\");\n    const pitchEl = $(\"phamhoPitch\");\n    const volumeEl = $(\"phamhoVolume\");\n    const rateVal = $(\"phamhoRateVal\");\n    const pitchVal = $(\"phamhoPitchVal\");\n    const volVal = $(\"phamhoVolVal\");\n\n    const btnSpeak = $(\"phamhoBtnSpeak\");\n    const btnPause = $(\"phamhoBtnPause\");\n    const btnResume = $(\"phamhoBtnResume\");\n    const btnStop = $(\"phamhoBtnStop\");\n    const btnClear = $(\"phamhoBtnClear\");\n\n    const presetEl = $(\"phamhoPreset\");\n    const chunkSizeEl = $(\"phamhoChunkSize\");\n\n    \/\/ Server MP3\n    const serverUrlEl = $(\"phamhoServerUrl\");\n    const btnServerTTS = $(\"phamhoBtnServerTTS\");\n    const audioBox = $(\"phamhoAudioBox\");\n    const audioEl = $(\"phamhoAudio\");\n    const btnDownloadMp3 = $(\"phamhoBtnDownloadMp3\");\n    const btnForgetAudio = $(\"phamhoBtnForgetAudio\");\n\n    let voices = [];\n    let queue = [];\n    let speaking = false;\n    let paused = false;\n\n    let currentAudioObjectUrl = null;\n\n    \/\/ ---------- helpers ----------\n    function setState(s){ stateEl.textContent = s; }\n    function updateCounters(){ charsEl.textContent = String(textEl.value.length); }\n    function isSupported(){ return (\"speechSynthesis\" in window) && (\"SpeechSynthesisUtterance\" in window); }\n    function normalizeVoiceName(v){ return (v.name || \"\").toLowerCase(); }\n\n    function setButtons(){\n      btnPause.disabled = !speaking || paused;\n      btnResume.disabled = !speaking || !paused;\n      btnStop.disabled = !speaking;\n      btnSpeak.disabled = speaking;\n    }\n\n    \/\/ ---------- voices ----------\n    function applyVoiceFilter(list){\n      const mode = filterEl.value;\n      if (mode === \"en\") return list.filter(v => (v.lang || \"\").toLowerCase().startsWith(\"en\"));\n      if (mode === \"google_uk_us\") {\n        return list.filter(v => {\n          const lang = (v.lang || \"\").toLowerCase();\n          const name = normalizeVoiceName(v);\n          const isGoogle = name.includes(\"google\");\n          const isUSUK = lang.startsWith(\"en-us\") || lang.startsWith(\"en-gb\");\n          return isGoogle && isUSUK;\n        });\n      }\n      return list;\n    }\n\n    function populateVoices(){\n      voices = window.speechSynthesis.getVoices() || [];\n      const filtered = applyVoiceFilter(voices);\n\n      voiceEl.innerHTML = \"\";\n      if (!filtered.length){\n        const opt = document.createElement(\"option\");\n        opt.value = \"\";\n        opt.textContent = \"No voices found (try reloading)\";\n        voiceEl.appendChild(opt);\n        voiceHint.textContent = \"Reload if no voices appear. Some browsers load voices asynchronously.\";\n        return;\n      }\n\n      filtered.forEach((v, idx) => {\n        const opt = document.createElement(\"option\");\n        opt.value = String(idx);\n        opt.textContent = `${v.name} \u2014 ${v.lang}`;\n        voiceEl.appendChild(opt);\n      });\n\n      const preferUS = filtered.findIndex(v => (v.lang || \"\").toLowerCase() === \"en-us\");\n      const preferGB = filtered.findIndex(v => (v.lang || \"\").toLowerCase() === \"en-gb\");\n      voiceEl.selectedIndex = (preferUS >= 0 ? preferUS : (preferGB >= 0 ? preferGB : 0));\n\n      voiceHint.textContent = `Loaded ${filtered.length} voice(s) from your browser.`;\n    }\n\n    function getSelectedVoice(){\n      const filtered = applyVoiceFilter(voices);\n      const idx = parseInt(voiceEl.value, 10);\n      if (!Number.isFinite(idx) || idx < 0 || idx >= filtered.length) return null;\n      return filtered[idx];\n    }\n\n    \/\/ ---------- chunking ----------\n    function chunkText(text, maxChars){\n      if (!maxChars || maxChars <= 0) return [text];\n      const cleaned = text.replace(\/\\s+\/g, \" \").trim();\n      if (!cleaned) return [];\n\n      const chunks = [];\n      let start = 0;\n      while (start < cleaned.length){\n        let end = Math.min(start + maxChars, cleaned.length);\n        const slice = cleaned.slice(start, end);\n        const lastPunct = Math.max(\n          slice.lastIndexOf(\". \"),\n          slice.lastIndexOf(\"! \"),\n          slice.lastIndexOf(\"? \"),\n          slice.lastIndexOf(\"; \"),\n          slice.lastIndexOf(\": \")\n        );\n        if (lastPunct > Math.max(30, maxChars * 0.55) && end < cleaned.length){\n          end = start + lastPunct + 1;\n        }\n        chunks.push(cleaned.slice(start, end).trim());\n        start = end;\n      }\n      return chunks.filter(Boolean);\n    }\n\n    \/\/ ---------- browser speak ----------\n    function speakNext(){\n      if (!queue.length){\n        speaking = false;\n        paused = false;\n        setState(\"idle\");\n        setButtons();\n        return;\n      }\n\n      const v = getSelectedVoice();\n      const nextText = queue.shift();\n      const u = new SpeechSynthesisUtterance(nextText);\n\n      if (v) u.voice = v;\n      u.rate = parseFloat(rateEl.value);\n      u.pitch = parseFloat(pitchEl.value);\n      u.volume = parseFloat(volumeEl.value);\n\n      u.onstart = () => { speaking = true; paused = false; setState(\"speaking\"); setButtons(); };\n      u.onend   = () => { if (!paused) speakNext(); };\n      u.onerror = () => { if (!paused) speakNext(); };\n\n      window.speechSynthesis.speak(u);\n    }\n\n    function startSpeak(){\n      const raw = textEl.value || \"\";\n      if (!raw.trim()) return;\n\n      window.speechSynthesis.cancel();\n      const maxChars = parseInt(chunkSizeEl.value, 10);\n      queue = chunkText(raw, maxChars);\n\n      speaking = true;\n      paused = false;\n\n      setState(\"queueing\");\n      setButtons();\n      speakNext();\n    }\n\n    function pauseSpeak(){\n      if (!speaking) return;\n      window.speechSynthesis.pause();\n      paused = true;\n      setState(\"paused\");\n      setButtons();\n    }\n\n    function resumeSpeak(){\n      if (!speaking) return;\n      window.speechSynthesis.resume();\n      paused = false;\n      setState(\"speaking\");\n      setButtons();\n    }\n\n    function stopSpeak(){\n      window.speechSynthesis.cancel();\n      queue = [];\n      speaking = false;\n      paused = false;\n      setState(\"idle\");\n      setButtons();\n    }\n\n    \/\/ ---------- server mp3 (blob url) ----------\n    function setAudioFromArrayBuffer(arrayBuffer){\n      if (currentAudioObjectUrl) URL.revokeObjectURL(currentAudioObjectUrl);\n\n      const blob = new Blob([arrayBuffer], { type: \"audio\/mpeg\" });\n      const url = URL.createObjectURL(blob);\n      currentAudioObjectUrl = url;\n\n      audioEl.src = url;\n      audioBox.classList.add(\"show\");\n\n      btnDownloadMp3.href = url;\n      btnDownloadMp3.download = \"tts.mp3\";\n      btnDownloadMp3.setAttribute(\"aria-disabled\", \"false\");\n    }\n\n    function forgetServerAudio(){\n      try { audioEl.pause(); } catch(e){}\n      audioEl.removeAttribute(\"src\");\n      audioEl.load();\n\n      if (currentAudioObjectUrl) URL.revokeObjectURL(currentAudioObjectUrl);\n      currentAudioObjectUrl = null;\n\n      btnDownloadMp3.href = \"#\";\n      btnDownloadMp3.removeAttribute(\"download\");\n      btnDownloadMp3.setAttribute(\"aria-disabled\", \"true\");\n      audioBox.classList.remove(\"show\");\n    }\n\n    async function fetchServerMp3(url, payload){\n      const res = await fetch(url, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application\/json\" },\n        body: JSON.stringify(payload),\n      });\n\n      if (!res.ok) {\n        const errTxt = await res.text().catch(() => \"\");\n        throw new Error(`Server error ${res.status}: ${errTxt || \"No details\"}`);\n      }\n\n      const ct = (res.headers.get(\"content-type\") || \"\").toLowerCase();\n      if (ct.includes(\"application\/json\") || ct.includes(\"text\/\")) {\n        const msg = await res.text().catch(() => \"\");\n        throw new Error(`Endpoint returned ${ct}. ${msg}`);\n      }\n\n      \/\/ Use ArrayBuffer to force correct mp3 mime when creating Blob\n      return await res.arrayBuffer();\n    }\n\n    \/\/ ---------- bindings ----------\n    textEl.addEventListener(\"input\", updateCounters);\n    filterEl.addEventListener(\"change\", populateVoices);\n\n    rateEl.addEventListener(\"input\", () => rateVal.textContent = Number(rateEl.value).toFixed(2));\n    pitchEl.addEventListener(\"input\", () => pitchVal.textContent = Number(pitchEl.value).toFixed(2));\n    volumeEl.addEventListener(\"input\", () => volVal.textContent = Number(volumeEl.value).toFixed(2));\n\n    btnSpeak.addEventListener(\"click\", startSpeak);\n    btnPause.addEventListener(\"click\", pauseSpeak);\n    btnResume.addEventListener(\"click\", resumeSpeak);\n    btnStop.addEventListener(\"click\", stopSpeak);\n    btnClear.addEventListener(\"click\", () => { textEl.value = \"\"; updateCounters(); });\n\n    btnForgetAudio.addEventListener(\"click\", forgetServerAudio);\n\n    presetEl.addEventListener(\"change\", () => {\n      const v = presetEl.value;\n      if (v === \"lesson\"){\n        textEl.value =\n`Welcome, everyone. Today we will practice speaking fluently.\nFirst, read the text silently. Then, answer my questions in complete sentences.\nIf you make a mistake, try again. Take your time and speak clearly.`;\n      } else if (v === \"news\"){\n        textEl.value =\n`Global education is changing rapidly as AI tools become more accessible. Many teachers are exploring new ways to provide timely feedback and personalize learning, while also strengthening academic integrity and critical thinking.`;\n      } else if (v === \"dialogue\"){\n        textEl.value =\n`A: Good morning. How are you today?\nB: I'm doing well, thanks. How about you?\nA: Great. What are you working on this week?\nB: I'm preparing a short presentation for my class.`;\n      }\n      updateCounters();\n      presetEl.value = \"\";\n    });\n\n    btnServerTTS.addEventListener(\"click\", async () => {\n      const url = (serverUrlEl.value || \"\").trim();\n      const txt = (textEl.value || \"\").trim();\n\n      if (!url) { alert(\"Please paste a Server TTS URL that returns audio\/mpeg (MP3).\"); return; }\n      if (!txt) { alert(\"Please enter some text.\"); return; }\n\n      btnServerTTS.disabled = true;\n      btnServerTTS.textContent = \"Generating\u2026\";\n\n      try {\n        \/\/ For your Murf WP endpoint:\n        const payload = { text: txt, voiceId: \"en-US-natalie\" };\n        const buf = await fetchServerMp3(url, payload);\n        setAudioFromArrayBuffer(buf);\n        audioEl.play().catch(()=>{});\n      } catch (e) {\n        console.error(e);\n        alert(String(e && e.message ? e.message : e));\n      } finally {\n        btnServerTTS.disabled = false;\n        btnServerTTS.textContent = \"Generate (Server)\";\n      }\n    });\n\n    window.addEventListener(\"beforeunload\", () => {\n      if (currentAudioObjectUrl) URL.revokeObjectURL(currentAudioObjectUrl);\n    });\n\n    \/\/ ---------- init ----------\n    (function init(){\n      \/\/ Download disabled until server audio is generated\n      btnDownloadMp3.setAttribute(\"aria-disabled\", \"true\");\n\n      updateCounters();\n      rateVal.textContent = Number(rateEl.value).toFixed(2);\n      pitchVal.textContent = Number(pitchEl.value).toFixed(2);\n      volVal.textContent = Number(volumeEl.value).toFixed(2);\n\n      if (!isSupported()){\n        supportPill.textContent = \"Web Speech API: NOT supported\";\n        supportPill.style.borderColor = \"rgba(239,68,68,.55)\";\n        supportPill.style.color = \"#fecaca\";\n        supportPill.style.background = \"rgba(239,68,68,.12)\";\n        setState(\"unsupported\");\n        btnSpeak.disabled = true;\n        return;\n      }\n\n      supportPill.textContent = \"Web Speech API: Supported\";\n      setState(\"idle\");\n      setButtons();\n\n      populateVoices();\n      window.speechSynthesis.onvoiceschanged = populateVoices;\n    })();\n  })();\n  <\/script>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>ICTE Robots: Text-to-Speech Type or paste text, pick a voice, then Speak \/ Pause \/ Resume \/ Stop. Works with your browser\u2019s built-in voices. Checking support\u2026 Text Your text Quick presets \u2014 Choose a sample \u2014Classroom instructionNews style paragraphShort dialogue Chunk length (chars) 220 (safer)350 (balanced)600 (longer)No chunking (may fail on long text) Speak Pause&hellip;<\/p>\n","protected":false},"author":1,"featured_media":1393,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[34,51],"tags":[],"class_list":["post-1887","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-robots","category-text-to-speech"],"featured_image_src":"https:\/\/phamho.com\/book\/wp-content\/uploads\/2024\/06\/DALL\u00b7E-2024-06-09-12.04.13-A-group-of-secondary-school-girls-and-boys-talking-in-a-school-hallway.-The-students-are-wearing-school-uniforms-with-girls-in-skirts-and-boys-in-tro-1024x585.webp","blog_images":{"medium":"https:\/\/phamho.com\/book\/wp-content\/uploads\/2024\/06\/DALL\u00b7E-2024-06-09-12.04.13-A-group-of-secondary-school-girls-and-boys-talking-in-a-school-hallway.-The-students-are-wearing-school-uniforms-with-girls-in-skirts-and-boys-in-tro-300x171.webp","large":"https:\/\/phamho.com\/book\/wp-content\/uploads\/2024\/06\/DALL\u00b7E-2024-06-09-12.04.13-A-group-of-secondary-school-girls-and-boys-talking-in-a-school-hallway.-The-students-are-wearing-school-uniforms-with-girls-in-skirts-and-boys-in-tro-1024x585.webp"},"ams_acf":[],"_links":{"self":[{"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/posts\/1887","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/comments?post=1887"}],"version-history":[{"count":10,"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/posts\/1887\/revisions"}],"predecessor-version":[{"id":1898,"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/posts\/1887\/revisions\/1898"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/media\/1393"}],"wp:attachment":[{"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/media?parent=1887"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/categories?post=1887"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/phamho.com\/book\/wp-json\/wp\/v2\/tags?post=1887"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}