{"id":116853,"date":"2026-05-27T12:06:42","date_gmt":"2026-05-27T12:06:42","guid":{"rendered":"https:\/\/radiojingles24.com\/?page_id=116853"},"modified":"2026-08-22T10:40:59","modified_gmt":"2026-08-22T10:40:59","slug":"ai-studio","status":"publish","type":"page","link":"https:\/\/radiojingles24.com\/it\/ai-studio\/","title":{"rendered":"AI Studio"},"content":{"rendered":"\n\t\t<div id=\"fws_6a8cc48e8f56c\"  data-midnight=\"dark\"  data-bg-mobile-hidden=\"\" class=\"wpb_row vc_row-fluid vc_row top-level standard_section   \"  style=\"padding-top: 0px; padding-bottom: 0px; \"><div class=\"row-bg-wrap\" data-bg-animation=\"none\"><div class=\"inner-wrap \"><div class=\"row-bg    \"  style=\"\"><\/div><\/div><div class=\"row-bg-overlay\" ><\/div><\/div><div class=\"col span_12 dark left\">\n\t<div  class=\"vc_col-sm-12 wpb_column column_container vc_column_container col no-extra-padding\"  data-t-w-inherits=\"default\" data-border-radius=\"none\" data-shadow=\"none\" data-border-animation=\"\" data-border-animation-delay=\"\" data-border-width=\"none\" data-border-style=\"solid\" data-border-color=\"\" data-bg-cover=\"\" data-padding-pos=\"all\" data-has-bg-color=\"false\" data-bg-color=\"\" data-bg-opacity=\"1\" data-hover-bg=\"\" data-hover-bg-opacity=\"1\" data-animation=\"\" data-delay=\"0\"><div class=\"column-bg-overlay\"><\/div>\n\t\t<div class=\"vc_column-inner\">\n\t\t\t<div class=\"wpb_wrapper\">\n\t\t\t\t\n\t<div class=\"wpb_raw_code wpb_content_element wpb_raw_html\" >\n\t\t<div class=\"wpb_wrapper\">\n\t\t\t<!--\n  RADIO JINGLES 24 \u2014 AI STUDIO  (page 1: Adverts)\n  ============================================================================\n  WHAT THIS IS\n  A standalone \"AI Studio\" page, separate from the human-sung products. First\n  tool live: AI Advert maker. Flow:\n    1) Type the advert script\n    2) Pick a music type (Corporate \/ Party \/ Classical \/ Upbeat \/ Something else+prompt)\n    3) Pick an AI voice\n    4) Create -> TTS voiceover + AI music bed, mixed in the browser with the\n       music DUCKED under the voice and FADED OUT as the advert ends -> preview\n    5) Buy \/ download (wired to WooCommerce credits later)\n\n  HOW IT TALKS TO THE BACKEND  (window.RJ24_AISTUDIO_CFG)\n    - cfg.ttsUrl       : POST {text, voice} -> { audioUrl }  (ElevenLabs via your plugin)\n    - cfg.musicUrl     : POST {genre, prompt, seconds} -> { taskId } then poll\n    - cfg.musicPollUrl : GET  ?taskId= -> { status, audioUrl }\n    - cfg.voices       : [{id,name,demo}]\n    - cfg.genres       : [{key,label,desc}]\n    - cfg.credits, cfg.loggedIn, cfg.accountUrl, cfg.subscribeUrl, cfg.topUpUrl\n  Until those exist, the page runs in DEMO mode (uses placeholder audio so the\n  whole experience is clickable). Set cfg.demo=false when the backend is ready.\n  ============================================================================\n-->\n<link href=\"https:\/\/fonts.googleapis.com\/css2?family=Anton&family=Inter:wght@400;500;600;700;800&display=swap\" rel=\"stylesheet\">\n<!-- v1.6.1: lamejs for client-side WAV->MP3 encoding before saving creations.\n     Loaded async so it doesn't block initial render. We don't need it until\n     the customer creates something, by which point it's almost always loaded. -->\n<script async src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/lamejs\/1.2.1\/lame.min.js\"><\/script>\n<style>\n#rj-ai-studio{\n  --pink:#EC4899; --pink-dark:#DB2777; --pink-light:#FDF2F8; --ink:#1E293B; --text:#475569; --muted:#94A3B8; --line:#E2E8F0; --gold:#F59E0B; --cream:#F4F2EC;\n  font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; color:var(--ink); max-width:1000px; margin:0 auto; padding:24px 16px 160px; -webkit-font-smoothing:antialiased; line-height:1.5;\n}\n#rj-ai-studio *{ box-sizing:border-box; }\n\n\/* HERO *\/\n#rj-ai-studio .ais-hero{ position:relative; isolation:isolate; overflow:hidden; border-radius:26px; padding:56px 40px; text-align:center; margin-bottom:14px;\n  background-color:#0B0712;\n  background-image:\n    radial-gradient(120% 130% at 10% 12%, rgba(236,72,153,0.55) 0%, rgba(236,72,153,0) 52%),\n    radial-gradient(120% 130% at 92% 8%, rgba(245,158,11,0.34) 0%, rgba(245,158,11,0) 48%),\n    radial-gradient(120% 140% at 80% 100%, rgba(99,102,241,0.34) 0%, rgba(99,102,241,0) 55%),\n    linear-gradient(135deg,#241733 0%,#0B0712 100%);\n}\n#rj-ai-studio .ais-hero::after{ content:\"\"; position:absolute; inset:0; z-index:-1; background-image:radial-gradient(rgba(255,255,255,0.05) 1.5px,transparent 1.5px); background-size:30px 30px; opacity:.5; }\n#rj-ai-studio .ais-badge{ display:inline-flex; align-items:center; gap:8px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.25); color:#fff; font-size:11px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; padding:8px 18px; border-radius:999px; margin-bottom:18px; }\n#rj-ai-studio .ais-badge .dot{ width:7px; height:7px; border-radius:50%; background:#34D399; box-shadow:0 0 10px #34D399; }\n#rj-ai-studio .ais-hero h1{ font-family:'Anton',sans-serif; text-transform:uppercase; font-size:clamp(40px,7vw,76px); line-height:.92; color:#fff; margin:0; letter-spacing:-1px; text-shadow:0 6px 44px rgba(236,72,153,.4); }\n#rj-ai-studio .ais-hero h1 .pop{ background:linear-gradient(180deg,#FBCFE8,#EC4899 65%,#DB2777); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }\n#rj-ai-studio .ais-hero p{ color:rgba(255,255,255,0.84); font-size:17px; max-width:560px; margin:18px auto 0; }\n#rj-ai-studio .ais-hero .ais-mini{ margin-top:22px; display:flex; gap:22px; justify-content:center; flex-wrap:wrap; color:rgba(255,255,255,0.7); font-size:13px; font-weight:600; }\n#rj-ai-studio .ais-hero .ais-mini span{ display:inline-flex; align-items:center; gap:7px; }\n\n\/* SEPARATE-FROM-HUMAN note *\/\n#rj-ai-studio .ais-note{ background:#FFFBEB; border:1px solid #FDE68A; color:#92400E; border-radius:14px; padding:14px 18px; font-size:13.5px; line-height:1.5; margin:14px 0 26px; text-align:center; }\n#rj-ai-studio .ais-note b{ color:#78350F; }\n\n\/* TOOL TABS (Adverts live; others \"soon\") *\/\n#rj-ai-studio .ais-tabs{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:22px; }\n#rj-ai-studio .ais-tab{ flex:1; min-width:130px; border:2px solid var(--line); background:#fff; border-radius:14px; padding:14px 12px; text-align:center; cursor:pointer; font-weight:800; font-size:14px; color:var(--ink); transition:border-color .15s, background .15s; position:relative; }\n#rj-ai-studio .ais-tab small{ display:block; font-weight:600; font-size:11px; color:var(--muted); margin-top:2px; }\n#rj-ai-studio .ais-tab.active{ border-color:var(--pink); background:var(--pink-light); color:var(--pink-dark); }\n#rj-ai-studio .ais-tab.soon{ opacity:.6; cursor:not-allowed; }\n#rj-ai-studio .ais-tab .soon-tag{ position:absolute; top:-9px; right:10px; background:var(--ink); color:#fff; font-size:9px; font-weight:800; letter-spacing:.08em; padding:3px 8px; border-radius:999px; text-transform:uppercase; }\n\n\/* PANEL *\/\n#rj-ai-studio .ais-panel{ background:#fff; border:1px solid var(--line); border-radius:18px; padding:26px; }\n#rj-ai-studio .ais-step{ margin-bottom:26px; }\n#rj-ai-studio .ais-step:last-child{ margin-bottom:0; }\n#rj-ai-studio .ais-step-h{ display:flex; align-items:center; gap:10px; font-size:16px; font-weight:800; margin:0 0 4px; }\n#rj-ai-studio .ais-step-n{ width:26px; height:26px; border-radius:50%; background:var(--pink); color:#fff; font-size:13px; display:flex; align-items:center; justify-content:center; flex:none; }\n#rj-ai-studio .ais-step-sub{ font-size:13px; color:var(--text); margin:0 0 14px; padding-left:36px; }\n\n\/* RJ24-EDIT (Nov 2026): scripting guide card \u2014 appears above every script textarea.\n   Prevents the #1 source of bad AI output: badly-formatted scripts. *\/\n#rj-ai-studio .ais-scripting-guide{\n  background:#FBF7F2; border:1px solid #F3E8DD; border-left:3px solid #EC4899;\n  border-radius:12px; padding:16px 18px; margin:0 0 16px;\n  font-size:13px; line-height:1.55; color:#475569;\n}\n#rj-ai-studio .ais-scripting-guide-h{ display:flex; align-items:center; gap:8px; font-weight:800; color:#1E293B; font-size:14px; margin-bottom:10px; }\n#rj-ai-studio .ais-scripting-guide-h .ico{ font-size:16px; }\n#rj-ai-studio .ais-scripting-guide details{ margin-top:6px; }\n#rj-ai-studio .ais-scripting-guide summary{ cursor:pointer; font-weight:700; color:#DB2777; padding:4px 0; user-select:none; outline:none; }\n#rj-ai-studio .ais-scripting-guide summary:hover{ color:#9D174D; }\n#rj-ai-studio .ais-scripting-guide summary::-webkit-details-marker{ display:none; }\n#rj-ai-studio .ais-scripting-guide summary::before{ content:\"\u25b8 \"; display:inline-block; transition:transform .2s; }\n#rj-ai-studio .ais-scripting-guide details[open] summary::before{ content:\"\u25be \"; }\n#rj-ai-studio .ais-scripting-guide-rules{ margin-top:10px; padding:0; list-style:none; }\n#rj-ai-studio .ais-scripting-guide-rules li{ padding:8px 0; border-top:1px dashed #F3E8DD; display:grid; grid-template-columns:1fr; gap:4px; }\n#rj-ai-studio .ais-scripting-guide-rules li:first-child{ border-top:none; padding-top:4px; }\n#rj-ai-studio .ais-scripting-guide-rules .rule-h{ font-weight:800; color:#1E293B; font-size:13px; }\n#rj-ai-studio .ais-scripting-guide-rules .rule-eg{ font-family:'SFMono-Regular',Menlo,monospace; font-size:12px; }\n#rj-ai-studio .ais-scripting-guide-rules .rule-eg .bad{ color:#DC2626; text-decoration:line-through; }\n#rj-ai-studio .ais-scripting-guide-rules .rule-eg .good{ color:#059669; font-weight:700; margin-left:6px; }\n#rj-ai-studio .ais-scripting-guide-rules .rule-d{ font-size:12px; color:#64748B; }\n@media (max-width:560px){\n  #rj-ai-studio .ais-scripting-guide{ padding:14px; }\n  #rj-ai-studio .ais-scripting-guide-rules .rule-eg{ word-break:break-word; }\n}\n\n#rj-ai-studio .ais-textarea{ width:100%; border:2px solid var(--line); border-radius:14px; padding:14px 16px; font-family:inherit; font-size:15px; color:var(--ink); resize:vertical; }\n#rj-ai-studio .ais-textarea:focus{ outline:none; border-color:var(--pink); box-shadow:0 0 0 4px rgba(236,72,153,.1); }\n#rj-ai-studio .ais-count{ margin-top:6px; font-size:12px; font-weight:700; color:var(--muted); }\n#rj-ai-studio .ais-delivery{ display:flex; gap:24px; flex-wrap:wrap; margin-top:14px; }\n#rj-ai-studio .ais-delivery-group{ display:flex; flex-direction:column; gap:6px; }\n#rj-ai-studio .ais-delivery-lab{ font-size:12px; font-weight:800; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }\n#rj-ai-studio .ais-pills{ display:flex; gap:6px; }\n#rj-ai-studio .ais-pills button{ border:2px solid var(--line); background:#fff; border-radius:10px; padding:8px 14px; font-weight:700; font-size:13px; color:var(--ink); cursor:pointer; font-family:inherit; }\n#rj-ai-studio .ais-pills button.active{ border-color:var(--pink); background:var(--pink-light); color:var(--pink-dark); }\n#rj-ai-studio .ais-pronounce{ margin-top:16px; }\n#rj-ai-studio .ais-pronounce-lab{ display:block; font-size:13px; font-weight:800; color:var(--ink); margin-bottom:6px; }\n#rj-ai-studio .ais-pronounce-lab span{ font-weight:600; color:var(--muted); }\n#rj-ai-studio .ais-target{ margin-top:14px; }\n#rj-ai-studio .ais-musiclevel{ margin-top:12px; }\n#rj-ai-studio .ais-musiclevel .ais-pills{ margin-top:4px; }\n#rj-ai-studio .ais-target .ais-pills{ margin-top:4px; }\n\n\/* genre grid *\/\n#rj-ai-studio .ais-genres{ display:grid; grid-template-columns:repeat(5,1fr); gap:10px; }\n@media (max-width:760px){ #rj-ai-studio .ais-genres{ grid-template-columns:repeat(3,1fr); } }\n@media (max-width:460px){ #rj-ai-studio .ais-genres{ grid-template-columns:repeat(2,1fr); } }\n#rj-ai-studio .ais-genre{ border:2px solid var(--line); border-radius:14px; padding:16px 10px; text-align:center; cursor:pointer; transition:border-color .15s, transform .12s, background .15s; }\n#rj-ai-studio .ais-genre:hover{ border-color:var(--pink); transform:translateY(-2px); }\n#rj-ai-studio .ais-genre.selected{ border-color:var(--pink); background:var(--pink-light); }\n#rj-ai-studio .ais-genre-ico{ font-size:26px; }\n#rj-ai-studio .ais-genre-name{ font-size:13px; font-weight:800; margin-top:6px; color:var(--ink); }\n#rj-ai-studio .ais-genre-desc{ font-size:10.5px; color:var(--muted); margin-top:2px; line-height:1.3; }\n\/* RJ24-EDIT (Nov 2026): compact variant of the genre tiles, used by the sting picker\n   inside sweeper\/DJ drop tabs \u2014 smaller padding and ico size so it doesn't dwarf the parent step. *\/\n#rj-ai-studio .ais-genres-compact{ grid-template-columns:repeat(6,1fr); gap:8px; }\n@media (max-width:760px){ #rj-ai-studio .ais-genres-compact{ grid-template-columns:repeat(3,1fr); } }\n@media (max-width:460px){ #rj-ai-studio .ais-genres-compact{ grid-template-columns:repeat(2,1fr); } }\n#rj-ai-studio .ais-genres-compact .ais-genre{ padding:10px 6px; }\n#rj-ai-studio .ais-genres-compact .ais-genre-ico{ font-size:18px; }\n#rj-ai-studio .ais-genres-compact .ais-genre-name{ font-size:11.5px; margin-top:4px; }\n#rj-ai-studio .ais-genres-compact .ais-genre-desc{ display:none; }\n#rj-ai-studio .ais-prompt-wrap{ margin-top:12px; display:none; }\n#rj-ai-studio .ais-prompt-wrap.show{ display:block; }\n#rj-ai-studio .ais-input{ width:100%; border:2px solid var(--line); border-radius:12px; padding:12px 14px; font-family:inherit; font-size:14px; }\n#rj-ai-studio .ais-input:focus{ outline:none; border-color:var(--pink); box-shadow:0 0 0 4px rgba(236,72,153,.1); }\n\n\/* voice grid *\/\n#rj-ai-studio .ais-voices{ display:grid; grid-template-columns:repeat(4,1fr); gap:10px; }\n@media (max-width:620px){ #rj-ai-studio .ais-voices{ grid-template-columns:repeat(2,1fr); } }\n#rj-ai-studio .ais-voice-controls{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; }\n#rj-ai-studio .ais-voice-controls .ais-input{ margin:0; }\n#rj-ai-studio .ais-voice-group{ grid-column:1\/-1; font-size:12px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); padding:8px 2px 2px; border-top:1px solid var(--line); margin-top:4px; }\n#rj-ai-studio .ais-voice-group:first-child{ border-top:none; margin-top:0; }\n#rj-ai-studio .ais-voice{ border:2px solid var(--line); border-radius:12px; padding:12px; cursor:pointer; transition:border-color .15s; display:flex; align-items:center; gap:10px; }\n#rj-ai-studio .ais-voice:hover{ border-color:var(--pink); }\n#rj-ai-studio .ais-voice.selected{ border-color:var(--pink); background:var(--pink-light); }\n#rj-ai-studio .ais-voice-play{ width:30px; height:30px; border-radius:50%; background:var(--pink); color:#fff; border:none; cursor:pointer; font-size:11px; flex:none; }\n#rj-ai-studio .ais-voice-meta b{ font-size:13px; display:block; }\n#rj-ai-studio .ais-voice-meta small{ font-size:11px; color:var(--muted); }\n#rj-ai-studio .ais-img-badge{ display:inline-block; margin-left:5px; background:var(--pink-light,#FCE7F3); color:var(--pink-dark,#DB2777); font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.04em; padding:1px 6px; border-radius:999px; vertical-align:middle; }\n\n\/* result *\/\n#rj-ai-studio .ais-result{ margin-top:22px; border:2px dashed var(--line); border-radius:16px; padding:24px; text-align:center; display:none; }\n#rj-ai-studio .ais-result.show{ display:block; }\n#rj-ai-studio .ais-downloads{ display:flex; gap:10px; flex-wrap:wrap; justify-content:center; margin-top:14px; }\n#rj-ai-studio .ais-dl-btn{ display:inline-block; background:var(--pink); color:#fff; font-weight:800; font-size:14px; padding:12px 24px; border-radius:999px; text-decoration:none; box-shadow:0 6px 18px rgba(236,72,153,.3); }\n#rj-ai-studio .ais-gen-spin{ width:38px; height:38px; border:4px solid var(--pink-light); border-top-color:var(--pink); border-radius:50%; animation:aisSpin .8s linear infinite; margin:6px auto 0; }\n@keyframes aisSpin{ to{ transform:rotate(360deg); } }\n#rj-ai-studio .ais-retry-btn{ display:inline-block; margin-top:8px; background:var(--ink); color:#fff; border:none; font-weight:700; font-size:13px; padding:8px 18px; border-radius:999px; cursor:pointer; }\n#rj-ai-studio .ais-retry-btn:hover{ background:var(--pink-dark); }\n#rj-ai-studio .sg-slot{ padding:12px 0; border-bottom:1px solid var(--line); }\n#rj-ai-studio .sg-slot:last-child{ border-bottom:none; }\n#rj-ai-studio .ais-dl-btn:hover{ background:var(--pink-dark); }\n#rj-ai-studio .ais-dl-secondary{ background:#fff; color:var(--ink); border:2px solid var(--line); box-shadow:none; }\n#rj-ai-studio .ais-dl-secondary:hover{ background:var(--cream); color:var(--ink); border-color:var(--pink); }\n#rj-ai-studio .ais-toggle{ display:flex; align-items:flex-start; gap:10px; font-size:14px; color:var(--ink); cursor:pointer; background:var(--cream); border-radius:12px; padding:14px 16px; }\n#rj-ai-studio .ais-toggle input{ margin-top:3px; width:18px; height:18px; flex:none; accent-color:var(--pink); }\n#rj-ai-studio .ais-cost{ margin-top:10px; font-size:14px; color:var(--text); }\n#rj-ai-studio .ais-cost b{ color:var(--pink-dark); }\n#rj-ai-studio .ais-produce{ margin-top:18px; background:#FFFBEB; border:1px solid #FDE68A; border-radius:14px; padding:18px; display:flex; align-items:center; gap:16px; flex-wrap:wrap; text-align:left; }\n#rj-ai-studio .ais-produce-body{ flex:1; min-width:200px; font-size:13.5px; color:#78350F; }\n#rj-ai-studio .ais-produce-body b{ color:#92400E; display:inline; }\n#rj-ai-studio .ais-produce-btn{ flex:none; background:var(--gold); color:#1E293B; font-weight:800; font-size:14px; border:none; border-radius:999px; padding:12px 22px; cursor:pointer; }\n#rj-ai-studio .ais-produce-btn:hover{ background:#D97706; color:#fff; }\n#rj-ai-studio .ais-result.ready{ border-style:solid; border-color:#34D399; background:#ECFDF5; }\n#rj-ai-studio .ais-result audio{ width:100%; margin-top:14px; }\n\n\/* Credits-short inline panel \u2014 replaces the harsh toast-then-redirect with a\n   clear two-choice CTA so customers can pick top-up or plans themselves. *\/\n#ais-credits-short{ display:none; position:fixed; left:50%; bottom:96px; transform:translateX(-50%); z-index:99998; background:#fff; border:2px solid #EC4899; border-radius:18px; box-shadow:0 16px 40px rgba(15,23,42,.25); padding:22px 26px; max-width:480px; width:calc(100% - 32px); box-sizing:border-box; font-family:'Inter',sans-serif; }\n#ais-credits-short.show{ display:block; animation:rj24csPop .25s cubic-bezier(.16,1,.3,1); }\n@keyframes rj24csPop{ from{ opacity:0; transform:translateX(-50%) translateY(8px); } to{ opacity:1; transform:translateX(-50%) translateY(0); } }\n#ais-credits-short .ais-cs-close{ position:absolute; top:8px; right:12px; background:none; border:none; font-size:24px; color:#94A3B8; cursor:pointer; line-height:1; padding:4px 8px; border-radius:6px; }\n#ais-credits-short .ais-cs-close:hover{ color:#1E293B; background:#F1F5F9; }\n#ais-credits-short .ais-cs-title{ font-family:'Anton',sans-serif; text-transform:uppercase; font-size:22px; color:#1E293B; margin:0 0 4px; letter-spacing:-.3px; }\n#ais-credits-short .ais-cs-detail{ color:#475569; font-size:14px; margin:0 0 16px; }\n#ais-credits-short .ais-cs-detail b{ color:#1E293B; }\n#ais-credits-short .ais-cs-actions{ display:flex; gap:10px; flex-wrap:wrap; }\n#ais-credits-short .ais-cs-actions a{ flex:1; min-width:140px; text-align:center; font-weight:700; font-size:14px; padding:11px 16px; border-radius:999px; text-decoration:none; transition:background .15s, transform .1s; }\n#ais-credits-short .ais-cs-topup{ background:#FFF1F8; color:#DB2777; border:1.5px solid #FBCFE8; }\n#ais-credits-short .ais-cs-topup:hover{ background:#FCE7F3; border-color:#F9A8D4; }\n#ais-credits-short .ais-cs-plans{ background:#EC4899; color:#fff; box-shadow:0 6px 18px rgba(236,72,153,.32); }\n#ais-credits-short .ais-cs-plans:hover{ background:#DB2777; }\n#rj-ai-studio .ais-spinner{ width:34px; height:34px; border:4px solid var(--line); border-top-color:var(--pink); border-radius:50%; margin:0 auto 12px; animation:ais-spin 1s linear infinite; }\n@keyframes ais-spin{ to{ transform:rotate(360deg); } }\n\n\/* footer bar *\/\n#rjais-footer{ position:fixed; left:0; right:0; bottom:0; z-index:99999; background:#fff; border-top:1px solid #E2E8F0; box-shadow:0 -4px 20px rgba(0,0,0,.08); display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 24px; font-family:'Inter',sans-serif; box-sizing:border-box; }\n#rjais-footer .info{ display:flex; flex-direction:column; line-height:1.15; }\n#rjais-footer .info .lab{ font-size:11px; color:#94A3B8; font-weight:700; text-transform:uppercase; letter-spacing:.05em; }\n#rjais-footer .info .val{ font-size:14px; color:#1E293B; font-weight:600; }\n#rjais-footer button{ background:#EC4899; color:#fff; border:none; border-radius:999px; font-weight:800; font-size:16px; padding:14px 34px; cursor:pointer; box-shadow:0 6px 18px rgba(236,72,153,.32); font-family:inherit; }\n#rjais-footer button:hover{ background:#DB2777; }\n#rjais-footer button:disabled{ background:#94A3B8; box-shadow:none; cursor:wait; }\n#rjais-footer .ais-foot-actions{ display:flex; align-items:center; gap:10px; }\n#rjais-footer a.ais-foot-link{ display:inline-flex; align-items:center; gap:6px; background:#FFF1F8; color:#DB2777; border:1.5px solid #FBCFE8; border-radius:999px; font-weight:700; font-size:13px; padding:9px 16px; text-decoration:none; transition:background .15s, border-color .15s; }\n#rjais-footer a.ais-foot-link:hover{ background:#FCE7F3; border-color:#F9A8D4; }\n#rjais-footer a.ais-foot-link.solid{ background:#1E293B; color:#fff; border-color:#1E293B; }\n#rjais-footer a.ais-foot-link.solid:hover{ background:#0F172A; }\n@media (max-width:700px){\n  #rjais-footer{ flex-wrap:wrap; gap:8px; padding:10px 14px; }\n  #rjais-footer .info{ flex:1 1 100%; }\n  #rjais-footer .ais-foot-actions{ flex-wrap:wrap; width:100%; justify-content:flex-end; }\n  #rjais-footer a.ais-foot-link{ font-size:12px; padding:7px 12px; }\n}\n#rjais-toast{ position:fixed; left:50%; transform:translateX(-50%); bottom:90px; z-index:100001; background:#10B981; color:#fff; font-weight:700; font-size:14px; padding:14px 28px; border-radius:999px; box-shadow:0 10px 30px rgba(16,185,129,.5); display:none; font-family:'Inter',sans-serif; }\n#rjais-toast.error{ background:#DC2626; }\n\n\/* how it works strip *\/\n#rj-ai-studio .ais-how{ margin-top:34px; display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }\n@media (max-width:680px){ #rj-ai-studio .ais-how{ grid-template-columns:1fr; } }\n#rj-ai-studio .ais-how-card{ background:var(--cream); border-radius:14px; padding:20px; }\n#rj-ai-studio .ais-how-num{ font-family:'Anton',sans-serif; font-size:30px; color:var(--pink); line-height:1; }\n#rj-ai-studio .ais-how-card h4{ margin:8px 0 4px; font-size:15px; }\n#rj-ai-studio .ais-how-card p{ margin:0; font-size:13px; color:var(--text); }\n\n\/* Slim header for logged-in regulars *\/\n#rj-ai-studio .ais-slim{ display:flex; align-items:center; justify-content:space-between; gap:12px; background:var(--ink,#1E293B); color:#fff; border-radius:14px; padding:14px 20px; margin-bottom:18px; }\n#rj-ai-studio .ais-slim-badge{ font-family:'Anton',sans-serif; font-size:18px; letter-spacing:.5px; }\n#rj-ai-studio .ais-slim-sub{ font-size:13px; opacity:.75; margin-left:6px; }\n#rj-ai-studio .ais-slim-toggle{ background:rgba(255,255,255,.12); color:#fff; border:1px solid rgba(255,255,255,.25); border-radius:999px; font-size:12px; font-weight:700; padding:8px 14px; cursor:pointer; }\n#rj-ai-studio .ais-slim-toggle:hover{ background:rgba(255,255,255,.2); }\n\n\/* AI + refunds disclaimer *\/\n#rj-ai-studio .ais-disclaimer{ display:flex; gap:14px; background:#FFF7ED; border:1px solid #FED7AA; border-radius:14px; padding:16px 18px; margin-bottom:20px; font-size:13.5px; line-height:1.55; color:#7C2D12; }\n#rj-ai-studio .ais-disc-icon{ font-size:22px; flex:none; }\n#rj-ai-studio .ais-disclaimer b{ color:#7C2D12; }\n#rj-ai-studio .ais-disc-human{ margin-top:8px; padding-top:8px; border-top:1px solid #FED7AA; }\n#rj-ai-studio .ais-disclaimer a{ color:#B45309; font-weight:700; }\n\n\/* Short create-bar disclaimer line *\/\n#rj-ai-studio .ais-create-disc{ font-size:11px; color:var(--muted,#94A3B8); text-align:center; margin-top:8px; }\n\n\/* Demo players *\/\n#rj-ai-studio .ais-demos{ background:var(--cream,#FBF7F2); border-radius:16px; padding:22px; margin-bottom:22px; }\n#rj-ai-studio .ais-demos-h{ font-family:'Anton',sans-serif; font-size:22px; margin:0 0 14px; color:var(--ink,#1E293B); }\n#rj-ai-studio .ais-demo-grid{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:12px; }\n#rj-ai-studio .ais-demo{ display:flex; align-items:center; gap:10px; background:#fff; border:1px solid var(--line,#E2E8F0); border-radius:12px; padding:12px 14px; }\n#rj-ai-studio .ais-demo-ico{ font-size:20px; flex:none; }\n#rj-ai-studio .ais-demo-meta{ flex:1; min-width:0; }\n#rj-ai-studio .ais-demo-meta b{ display:block; font-size:13px; }\n#rj-ai-studio .ais-demo-meta small{ font-size:11px; color:var(--muted,#94A3B8); }\n#rj-ai-studio .ais-demo-play{ flex:none; width:34px; height:34px; border-radius:50%; border:none; background:var(--pink,#EC4899); color:#fff; font-size:13px; cursor:pointer; }\n#rj-ai-studio .ais-demo-play:hover{ background:var(--pink-dark,#DB2777); }\n#rj-ai-studio .ais-demo.is-empty{ opacity:.55; }\n#rj-ai-studio .ais-demo.is-empty .ais-demo-play{ background:#CBD5E1; cursor:default; }\n#rj-ai-studio .ais-demos-note{ font-size:11px; color:var(--muted,#94A3B8); margin:12px 0 0; }\n#rjais-footer .ais-foot-disc{ display:block; font-size:10px; color:#94A3B8; margin-top:2px; letter-spacing:.02em; }\n\n\/* v1.6.1: My Creations tab styles. Card grid showing each saved creation. *\/\n#rj-ai-studio .ais-creations-head{ display:flex; align-items:flex-start; justify-content:space-between; gap:18px; flex-wrap:wrap; margin-bottom:20px; padding-bottom:16px; border-bottom:1px solid var(--line); }\n#rj-ai-studio .ais-creations-title{ font-family:'Anton',sans-serif; font-size:28px; line-height:1.05; color:var(--ink); margin:0 0 6px; text-transform:uppercase; letter-spacing:-.3px; }\n#rj-ai-studio .ais-creations-sub{ font-size:13px; color:var(--text); margin:0; line-height:1.5; }\n#rj-ai-studio .ais-creations-meta{ flex:none; text-align:right; font-size:12px; color:var(--muted); line-height:1.5; }\n#rj-ai-studio .ais-creations-meta b{ color:var(--ink); font-size:14px; }\n#rj-ai-studio .ais-creations-banner{ background:linear-gradient(135deg, #FDF2F8 0%, #FCE7F3 100%); border:1px solid #FBCFE8; border-radius:14px; padding:16px 18px; margin-bottom:20px; font-size:13.5px; line-height:1.55; color:#9D174D; display:flex; align-items:center; gap:12px; flex-wrap:wrap; }\n#rj-ai-studio .ais-creations-banner b{ color:#831843; }\n#rj-ai-studio .ais-creations-banner .rj-btn{ display:inline-block; background:#EC4899; color:#fff; font-weight:800; font-size:13px; padding:8px 16px; border-radius:999px; text-decoration:none; margin-left:auto; }\n#rj-ai-studio .ais-creations-banner .rj-btn:hover{ background:#DB2777; color:#fff; }\n#rj-ai-studio .ais-creations-banner .rj-btn-secondary{ background:#fff; color:#DB2777; border:1.5px solid #FBCFE8; margin-left:8px; }\n#rj-ai-studio .ais-creations-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:14px; }\n#rj-ai-studio .ais-creation-card{ background:#fff; border:1px solid var(--line); border-radius:14px; padding:16px; display:flex; flex-direction:column; gap:10px; transition:border-color .15s, box-shadow .15s; }\n#rj-ai-studio .ais-creation-card:hover{ border-color:#FBCFE8; box-shadow:0 4px 12px rgba(236,72,153,.08); }\n#rj-ai-studio .ais-creation-top{ display:flex; justify-content:space-between; align-items:flex-start; gap:8px; }\n#rj-ai-studio .ais-creation-type{ display:inline-flex; align-items:center; gap:5px; background:var(--pink-light); color:var(--pink-dark); font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.06em; padding:3px 9px; border-radius:999px; }\n#rj-ai-studio .ais-creation-date{ font-size:11px; color:var(--muted); font-weight:600; }\n#rj-ai-studio .ais-creation-script{ font-size:13px; color:var(--ink); line-height:1.4; max-height:3.5em; overflow:hidden; text-overflow:ellipsis; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }\n#rj-ai-studio .ais-creation-script.empty{ color:var(--muted); font-style:italic; }\n#rj-ai-studio .ais-creation-meta{ font-size:11px; color:var(--muted); line-height:1.4; }\n#rj-ai-studio .ais-creation-meta b{ color:var(--text); font-weight:600; }\n#rj-ai-studio .ais-creation-audio{ width:100%; margin-top:2px; }\n#rj-ai-studio .ais-creation-actions{ display:flex; gap:8px; margin-top:auto; padding-top:8px; border-top:1px solid var(--line); }\n#rj-ai-studio .ais-creation-actions .rj-btn-mini{ flex:1; text-align:center; padding:8px 12px; border-radius:8px; font-size:12px; font-weight:700; cursor:pointer; border:1px solid var(--line); background:#fff; color:var(--text); text-decoration:none; transition:background .12s, color .12s, border-color .12s; }\n#rj-ai-studio .ais-creation-actions .rj-btn-mini:hover{ background:var(--pink-light); color:var(--pink-dark); border-color:#FBCFE8; }\n#rj-ai-studio .ais-creation-actions .rj-btn-mini.rj-danger:hover{ background:#FEE2E2; color:#B91C1C; border-color:#FCA5A5; }\n#rj-ai-studio .ais-creation-expires{ font-size:10.5px; color:var(--muted); font-weight:600; }\n#rj-ai-studio .ais-creation-expires.warn{ color:#B45309; }\n#rj-ai-studio .ais-creations-empty{ text-align:center; padding:50px 20px; color:var(--muted); border:2px dashed var(--line); border-radius:14px; }\n#rj-ai-studio .ais-creations-empty .ico{ font-size:48px; opacity:.4; margin-bottom:10px; }\n#rj-ai-studio .ais-creations-empty h3{ font-family:'Anton',sans-serif; font-size:22px; color:var(--ink); margin:0 0 6px; }\n#rj-ai-studio .ais-creations-empty p{ margin:0; font-size:14px; }\n#rj-ai-studio .ais-creations-loading{ text-align:center; padding:40px 20px; color:var(--muted); }\n#rj-ai-studio .ais-save-pill{ display:inline-flex; align-items:center; gap:4px; background:#FEF3C7; color:#92400E; font-size:11px; font-weight:700; padding:3px 10px; border-radius:999px; margin-top:6px; }\n#rj-ai-studio .ais-save-pill.saved{ background:#D1FAE5; color:#065F46; }\n#rj-ai-studio .ais-save-pill.failed{ background:#FEE2E2; color:#991B1B; }\n#rj-ai-studio .ais-save-note{ font-size:11px; color:var(--muted); margin-top:6px; line-height:1.5; text-align:center; }\n\n\/* RJ24-EDIT (Jul 2026, SFX picker + free-first): styles for the new browsable\n   SFX picker (glitch\/separator, listen + pick up to 2) and the free-first\n   creation badge\/banner. *\/\n#rj-ai-studio .ais-sfx-picker{ display:grid; gap:14px; }\n#rj-ai-studio .ais-sfx-picker .ais-sfx-cat{ border:1px solid var(--line); border-radius:14px; padding:14px 16px; }\n#rj-ai-studio .ais-sfx-picker .ais-sfx-cat-h{ font-weight:800; font-size:13px; color:var(--ink); text-transform:uppercase; letter-spacing:.04em; margin-bottom:10px; display:flex; align-items:center; justify-content:space-between; }\n#rj-ai-studio .ais-sfx-picker .ais-sfx-cat-h small{ font-weight:600; color:var(--muted); text-transform:none; letter-spacing:0; }\n#rj-ai-studio .ais-sfx-list{ display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:8px; }\n#rj-ai-studio .ais-sfx-item{ display:flex; align-items:center; gap:8px; border:2px solid var(--line); border-radius:10px; padding:8px 10px; cursor:pointer; transition:border-color .12s, background .12s; }\n#rj-ai-studio .ais-sfx-item:hover{ border-color:#F9A8D4; }\n#rj-ai-studio .ais-sfx-item.selected{ border-color:var(--pink); background:var(--pink-light); }\n#rj-ai-studio .ais-sfx-item.disabled{ opacity:.4; cursor:not-allowed; }\n#rj-ai-studio .ais-sfx-item input[type=checkbox]{ flex:none; width:16px; height:16px; accent-color:var(--pink); }\n#rj-ai-studio .ais-sfx-play{ flex:none; width:26px; height:26px; border-radius:50%; background:var(--ink); color:#fff; border:none; cursor:pointer; font-size:10px; }\n#rj-ai-studio .ais-sfx-item-name{ flex:1; min-width:0; font-size:12px; font-weight:600; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n#rj-ai-studio .ais-sfx-picker-empty{ font-size:13px; color:var(--muted); padding:8px 0; }\n#rj-ai-studio .ais-sfx-picker-hint{ font-size:12px; color:var(--muted); margin-top:6px; }\n#rj-ai-studio .ais-sfx-picker-hint b{ color:var(--pink-dark); }\n\n\/* Free-first creation badge (shown on a result that used the free grant) *\/\n#rj-ai-studio .ais-freefirst-badge{ display:inline-flex; align-items:center; gap:6px; background:#D1FAE5; color:#065F46; font-size:12px; font-weight:800; padding:5px 12px; border-radius:999px; margin:8px 0; }\n\n\/* RJ24-EDIT (Jul 2026): script assistant, takes picker, Say It Yourself *\/\n#rj-ai-studio .ais-assist-row{ display:flex; align-items:center; gap:10px; margin-top:8px; flex-wrap:wrap; }\n#rj-ai-studio .ais-assist-btn{ border:2px solid var(--pink); background:#fff; color:var(--pink-dark); font-weight:700; font-size:12.5px; padding:7px 14px; border-radius:999px; cursor:pointer; font-family:inherit; }\n#rj-ai-studio .ais-assist-btn:hover{ background:var(--pink-light); }\n#rj-ai-studio .ais-assist-btn:disabled{ opacity:.55; cursor:wait; }\n#rj-ai-studio .ais-assist-box{ display:none; margin-top:10px; background:#FDF2F8; border:1px solid #FBCFE8; border-radius:12px; padding:14px 16px; }\n#rj-ai-studio .ais-assist-box.show{ display:block; }\n#rj-ai-studio .ais-assist-box-label{ font-size:11px; font-weight:800; color:#9D174D; text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px; }\n#rj-ai-studio .ais-assist-box-text{ font-size:14px; color:#1E293B; line-height:1.5; white-space:pre-wrap; }\n#rj-ai-studio .ais-assist-box-actions{ display:flex; gap:8px; margin-top:10px; }\n#rj-ai-studio .ais-assist-box-actions button{ border-radius:999px; font-weight:700; font-size:12.5px; padding:7px 14px; cursor:pointer; font-family:inherit; border:none; }\n#rj-ai-studio .ais-assist-use{ background:var(--pink); color:#fff; }\n#rj-ai-studio .ais-assist-dismiss{ background:#fff; color:var(--text); border:1.5px solid var(--line) !important; }\n\n#rj-ai-studio .ais-takes{ margin-top:14px; text-align:left; }\n#rj-ai-studio .ais-takes-h{ font-size:12px; font-weight:800; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px; }\n#rj-ai-studio .ais-takes-row{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }\n#rj-ai-studio .ais-take-pill{ border:2px solid var(--line); background:#fff; border-radius:999px; padding:8px 16px; font-weight:700; font-size:13px; cursor:pointer; font-family:inherit; color:var(--ink); }\n#rj-ai-studio .ais-take-pill.active{ border-color:var(--pink); background:var(--pink-light); color:var(--pink-dark); }\n#rj-ai-studio .ais-take-pill:disabled{ opacity:.5; cursor:wait; }\n#rj-ai-studio .ais-take-more{ border:2px dashed var(--muted); background:#fff; color:var(--text); border-radius:999px; padding:8px 16px; font-weight:700; font-size:13px; cursor:pointer; font-family:inherit; }\n#rj-ai-studio .ais-take-more:hover{ border-color:var(--pink); color:var(--pink-dark); }\n#rj-ai-studio .ais-take-more:disabled{ opacity:.5; cursor:wait; }\n#rj-ai-studio .ais-takes-note{ font-size:11.5px; color:var(--muted); margin-top:6px; }\n\n#rj-ai-studio .ais-record-box{ border:2px dashed var(--line); border-radius:16px; padding:24px; text-align:center; }\n#rj-ai-studio .ais-record-btn{ width:64px; height:64px; border-radius:50%; background:#DC2626; color:#fff; border:none; font-size:22px; cursor:pointer; box-shadow:0 6px 18px rgba(220,38,38,.35); }\n#rj-ai-studio .ais-record-btn.recording{ background:#7F1D1D; animation:aisRecPulse 1.2s ease-in-out infinite; }\n@keyframes aisRecPulse{ 0%,100%{ box-shadow:0 0 0 0 rgba(220,38,38,.45); } 50%{ box-shadow:0 0 0 14px rgba(220,38,38,0); } }\n#rj-ai-studio .ais-record-status{ margin-top:12px; font-weight:700; font-size:14px; color:var(--ink); }\n#rj-ai-studio .ais-record-time{ font-size:24px; font-weight:800; color:var(--pink-dark); font-family:'Anton',sans-serif; margin-top:4px; }\n#rj-ai-studio .ais-record-preview{ margin-top:16px; }\n#rj-ai-studio .ais-record-upload{ margin-top:14px; font-size:12.5px; color:var(--muted); }\n#rj-ai-studio .ais-record-upload input{ margin-top:6px; }\n\n\/* RJ24-EDIT (Jul 2026, in-tab Say It Yourself): Type \/ Record mode toggle +\n   recording instructions, shared by the advert \/ sweeper \/ DJ drop \/ podcast tabs. *\/\n#rj-ai-studio .ais-modebar{ background:#F8FAFC; border:1px solid var(--line); border-radius:14px; padding:14px 16px; margin-bottom:20px; }\n#rj-ai-studio .ais-modebar .ais-delivery-lab{ display:block; margin-bottom:8px; }\n#rj-ai-studio .ais-modetoggle{ display:inline-flex; flex-wrap:wrap; }\n#rj-ai-studio .ais-modetoggle button{ min-width:150px; }\n#rj-ai-studio .ais-mode-hint{ margin:8px 0 0; font-size:12.5px; color:var(--muted); }\n#rj-ai-studio .ais-record-instructions{ background:#FFF7ED; border:1px solid #FED7AA; border-radius:12px; padding:14px 16px; margin-bottom:14px; font-size:13px; line-height:1.55; color:#7C2D12; }\n#rj-ai-studio .ais-record-instructions b{ color:#7C2D12; }\n#rj-ai-studio .ais-record-instructions ul{ margin:8px 0 0; padding-left:20px; }\n#rj-ai-studio .ais-record-instructions li{ margin:4px 0; }\n#rj-ai-studio .ais-record-instructions .ais-cap{ font-family:'Anton',sans-serif; letter-spacing:.3px; color:#B45309; }\n#rj-ai-studio .ais-record-voicenote{ display:none; background:#FDF2F8; border:1px solid #FBCFE8; border-radius:10px; padding:10px 12px; margin:0 0 12px; font-size:12.5px; color:#9D174D; }\n\n\/* ===== RJ24 v2 (Aug 2026): sell section ===== *\/\n\/* ---------- hero v2 ---------- *\/\n#rj-ai-studio .ais-hero2{\n  position:relative; isolation:isolate; overflow:hidden; border-radius:26px;\n  padding:60px 40px 52px; margin-bottom:14px; text-align:center;\n  background-color:#0B0712;\n  background-image:\n    radial-gradient(120% 130% at 10% 12%, rgba(236,72,153,0.55) 0%, rgba(236,72,153,0) 52%),\n    radial-gradient(120% 130% at 92% 8%, rgba(245,158,11,0.34) 0%, rgba(245,158,11,0) 48%),\n    radial-gradient(120% 140% at 80% 100%, rgba(99,102,241,0.34) 0%, rgba(99,102,241,0) 55%),\n    linear-gradient(135deg,#241733 0%,#0B0712 100%);\n}\n#rj-ai-studio .ais-hero2::after{\n  content:\"\"; position:absolute; inset:0; z-index:-1;\n  background-image:radial-gradient(rgba(255,255,255,0.05) 1.5px,transparent 1.5px);\n  background-size:30px 30px; opacity:.5;\n}\n#rj-ai-studio .ais-offer{\n  display:inline-flex; align-items:center; gap:9px;\n  background:#10B981; color:#03251A;\n  font-size:12px; font-weight:800; letter-spacing:.1em; text-transform:uppercase;\n  padding:9px 20px; border-radius:999px; margin-bottom:20px;\n  box-shadow:0 6px 22px rgba(16,185,129,.42);\n}\n#rj-ai-studio .ais-offer .dot{ width:7px; height:7px; border-radius:50%; background:#03251A; }\n#rj-ai-studio .ais-hero2 h1{\n  font-family:'Anton',sans-serif; text-transform:uppercase;\n  font-size:clamp(38px,6.6vw,74px); line-height:.94; color:#fff; margin:0;\n  letter-spacing:-1px; text-shadow:0 6px 44px rgba(236,72,153,.4);\n}\n#rj-ai-studio .ais-hero2 h1 .pop{\n  background:linear-gradient(180deg,#FBCFE8,#EC4899 65%,#DB2777);\n  -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;\n}\n#rj-ai-studio .ais-hero2 .ais-sub{\n  color:rgba(255,255,255,0.86); font-size:17.5px; line-height:1.6;\n  max-width:610px; margin:20px auto 0;\n}\n#rj-ai-studio .ais-hero2 .ais-sub b{ color:#fff; }\n#rj-ai-studio .ais-hero-cta{ display:flex; gap:12px; justify-content:center; flex-wrap:wrap; margin-top:28px; }\n#rj-ai-studio .ais-btn-primary{\n  display:inline-flex; align-items:center; gap:8px;\n  background:#EC4899; color:#fff; font-weight:800; font-size:16px;\n  padding:15px 32px; border-radius:999px; text-decoration:none; border:none; cursor:pointer;\n  font-family:inherit; box-shadow:0 8px 26px rgba(236,72,153,.45);\n  transition:background .15s, transform .12s;\n}\n#rj-ai-studio .ais-btn-primary:hover{ background:#DB2777; color:#fff; transform:translateY(-1px); }\n#rj-ai-studio .ais-btn-ghost{\n  display:inline-flex; align-items:center; gap:8px;\n  background:rgba(255,255,255,.09); color:#fff; font-weight:700; font-size:15px;\n  padding:15px 26px; border-radius:999px; text-decoration:none; cursor:pointer;\n  border:1px solid rgba(255,255,255,.28); font-family:inherit;\n  transition:background .15s;\n}\n#rj-ai-studio .ais-btn-ghost:hover{ background:rgba(255,255,255,.17); color:#fff; }\n#rj-ai-studio .ais-nocard{ margin-top:14px; font-size:12.5px; color:rgba(255,255,255,.62); }\n\n\/* ---------- proof strip ---------- *\/\n#rj-ai-studio .ais-proof{\n  display:grid; grid-template-columns:repeat(4,1fr); gap:1px;\n  background:var(--line); border:1px solid var(--line);\n  border-radius:16px; overflow:hidden; margin-bottom:26px;\n}\n@media (max-width:720px){ #rj-ai-studio .ais-proof{ grid-template-columns:repeat(2,1fr); } }\n#rj-ai-studio .ais-proof-cell{ background:#fff; padding:18px 16px; text-align:center; }\n#rj-ai-studio .ais-proof-n{\n  font-family:'Anton',sans-serif; font-size:28px; line-height:1; color:var(--pink-dark);\n  letter-spacing:-.5px;\n}\n#rj-ai-studio .ais-proof-l{ font-size:12.5px; color:var(--text); margin-top:6px; line-height:1.4; }\n\n\/* ---------- demos v2 ---------- *\/\n#rj-ai-studio .ais-demos2{\n  background:var(--cream); border:1px solid var(--line);\n  border-radius:18px; padding:24px; margin-bottom:24px;\n}\n#rj-ai-studio .ais-demos2-h{\n  font-family:'Anton',sans-serif; font-size:24px; margin:0 0 4px; color:var(--ink);\n  text-transform:uppercase; letter-spacing:-.3px;\n}\n#rj-ai-studio .ais-demos2-s{ font-size:13.5px; color:var(--text); margin:0 0 16px; }\n\n\/* ---------- why-us cards ---------- *\/\n#rj-ai-studio .ais-why{\n  display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:24px;\n}\n@media (max-width:860px){ #rj-ai-studio .ais-why{ grid-template-columns:repeat(2,1fr); } }\n@media (max-width:460px){ #rj-ai-studio .ais-why{ grid-template-columns:1fr; } }\n#rj-ai-studio .ais-why-card{\n  background:#fff; border:1px solid var(--line); border-radius:14px;\n  padding:18px 16px; display:flex; flex-direction:column; gap:6px;\n}\n#rj-ai-studio .ais-why-ico{ font-size:22px; line-height:1; }\n#rj-ai-studio .ais-why-card h4{\n  margin:2px 0 0; font-size:14.5px; font-weight:800; color:var(--ink); line-height:1.3;\n}\n#rj-ai-studio .ais-why-card p{ margin:0; font-size:12.5px; color:var(--text); line-height:1.5; }\n#rj-ai-studio .ais-why-card .ais-vs{\n  margin-top:auto; padding-top:8px; font-size:11.5px; color:var(--muted); font-style:italic;\n  border-top:1px dashed var(--line);\n}\n\n\/* ---------- trust bar ---------- *\/\n#rj-ai-studio .ais-trust{\n  display:flex; align-items:center; justify-content:center; gap:10px 26px;\n  flex-wrap:wrap; text-align:center;\n  background:var(--ink); color:#fff; border-radius:14px;\n  padding:16px 22px; margin-bottom:22px; font-size:13.5px; line-height:1.5;\n}\n#rj-ai-studio .ais-trust b{ color:#FBCFE8; }\n#rj-ai-studio .ais-trust span{ display:inline-flex; align-items:center; gap:7px; }\n\n\/* ---------- honesty note (moved down, softened) ---------- *\/\n#rj-ai-studio .ais-honest{\n  display:flex; gap:14px; background:#FAFAF9; border:1px solid var(--line);\n  border-radius:14px; padding:16px 18px; margin-bottom:22px;\n  font-size:13px; line-height:1.6; color:var(--text);\n}\n#rj-ai-studio .ais-honest .ico{ font-size:20px; flex:none; line-height:1.3; }\n#rj-ai-studio .ais-honest b{ color:var(--ink); }\n#rj-ai-studio .ais-honest a{ color:var(--pink-dark); font-weight:700; }\n#rj-ai-studio .ais-honest .row2{ margin-top:8px; padding-top:8px; border-top:1px solid var(--line); }\n\n@media (max-width:560px){\n  #rj-ai-studio .ais-hero2{ padding:42px 20px 40px; }\n  #rj-ai-studio .ais-btn-primary,\n  #rj-ai-studio .ais-btn-ghost{ width:100%; justify-content:center; }\n}\n\n\/* ===== RJ24 v2 (Aug 2026): production library picker ===== *\/\n\/* ---------- arrangement strip ---------- *\/\n#rj-ai-studio .ais-arr{\n  display:grid; grid-template-columns:repeat(3,1fr); gap:10px;\n  background:#FBF7F2; border:1px solid #F3E8DD; border-radius:14px;\n  padding:14px; margin-bottom:14px;\n}\n@media (max-width:620px){ #rj-ai-studio .ais-arr{ grid-template-columns:1fr; } }\n#rj-ai-studio .ais-arr-slot{\n  background:#fff; border:2px dashed var(--line); border-radius:12px;\n  padding:10px 12px; min-height:66px;\n  display:flex; flex-direction:column; justify-content:center; gap:3px;\n  transition:border-color .15s, background .15s;\n}\n#rj-ai-studio .ais-arr-slot.filled{ border-style:solid; border-color:var(--pink); background:var(--pink-light); }\n#rj-ai-studio .ais-arr-slot.active{ border-color:var(--gold); }\n\/* The slot you are aiming at. Deliberately loud - it is the thing that decides\n   where the next piece lands, and it used to be invisible. *\/\n#rj-ai-studio .ais-arr-slot[role=\"button\"]{ cursor:pointer; }\n#rj-ai-studio .ais-arr-slot[role=\"button\"]:hover{ border-color:#F9A8D4; }\n#rj-ai-studio .ais-arr-slot.aimed{\n  border-color:var(--pink); border-style:solid;\n  box-shadow:0 0 0 3px rgba(236,72,153,.18);\n}\n#rj-ai-studio .ais-arr-lab em{ font-style:normal; color:var(--pink-dark); font-weight:800; letter-spacing:0; text-transform:none; }\n#rj-ai-studio .ais-arr-slot[role=\"button\"]:focus-visible{ outline:2px solid var(--pink); outline-offset:2px; }\n\/* A comfortable target on a phone. *\/\n@media (max-width:560px){ #rj-ai-studio .ais-arr-slot{ min-height:60px; } }\n#rj-ai-studio .ais-arr-lab{\n  font-size:10.5px; font-weight:800; letter-spacing:.08em; text-transform:uppercase;\n  color:var(--muted); display:flex; align-items:center; gap:5px;\n}\n#rj-ai-studio .ais-arr-slot.filled .ais-arr-lab{ color:var(--pink-dark); }\n#rj-ai-studio .ais-arr-name{ font-size:13px; font-weight:700; color:var(--ink); line-height:1.3; word-break:break-word; }\n#rj-ai-studio .ais-arr-empty{ font-size:12px; color:var(--muted); font-style:italic; }\n#rj-ai-studio .ais-arr-meta{ font-size:11px; color:var(--muted); display:flex; align-items:center; gap:8px; }\n#rj-ai-studio .ais-arr-x{\n  background:none; border:none; color:var(--muted); cursor:pointer;\n  font-size:15px; line-height:1; padding:2px 4px; margin-left:auto; border-radius:4px;\n}\n#rj-ai-studio .ais-arr-x:hover{ color:#B91C1C; background:#FEE2E2; }\n#rj-ai-studio .ais-arr-actions{ grid-column:1\/-1; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }\n#rj-ai-studio .ais-arr-btn{\n  border:1.5px solid var(--line); background:#fff; color:var(--ink);\n  border-radius:999px; padding:6px 14px; font-size:12.5px; font-weight:700;\n  cursor:pointer; font-family:inherit;\n}\n#rj-ai-studio .ais-arr-btn:hover{ border-color:var(--pink); color:var(--pink-dark); }\n#rj-ai-studio .ais-arr-free{\n  margin-left:auto; font-size:11.5px; font-weight:800; color:#059669;\n  text-transform:uppercase; letter-spacing:.06em;\n}\n\n\/* ---------- filter bar ---------- *\/\n#rj-ai-studio .ais-lib-bar{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }\n#rj-ai-studio .ais-lib-search{ flex:2; min-width:180px; }\n#rj-ai-studio .ais-lib-roles,\n#rj-ai-studio .ais-lib-tags{ display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px; }\n#rj-ai-studio .ais-chip{\n  border:1.5px solid var(--line); background:#fff; color:var(--text);\n  border-radius:999px; padding:5px 12px; font-size:12px; font-weight:700;\n  cursor:pointer; font-family:inherit; white-space:nowrap;\n}\n#rj-ai-studio .ais-chip:hover{ border-color:#F9A8D4; }\n#rj-ai-studio .ais-chip.on{ border-color:var(--pink); background:var(--pink-light); color:var(--pink-dark); }\n#rj-ai-studio .ais-chip small{ font-weight:600; opacity:.7; margin-left:3px; }\n#rj-ai-studio .ais-chip-tag{ font-size:11.5px; padding:4px 10px; font-weight:600; }\n#rj-ai-studio .ais-lib-taggroup{ display:flex; gap:6px; flex-wrap:wrap; align-items:center; margin-bottom:6px; }\n#rj-ai-studio .ais-lib-taglabel{\n  font-size:10px; font-weight:800; letter-spacing:.1em; text-transform:uppercase;\n  color:var(--muted); min-width:42px; flex:none;\n}\n\n\/* ---------- rows ---------- *\/\n#rj-ai-studio .ais-lib-count{ font-size:12px; color:var(--muted); margin:0 0 8px; }\n#rj-ai-studio .ais-lib-list{ display:grid; gap:6px; }\n#rj-ai-studio .ais-lib-row{\n  display:flex; align-items:center; gap:10px;\n  border:1.5px solid var(--line); border-radius:10px; padding:8px 10px; background:#fff;\n}\n#rj-ai-studio .ais-lib-row.used{ border-color:var(--pink); background:var(--pink-light); }\n#rj-ai-studio .ais-lib-play{\n  flex:none; width:30px; height:30px; border-radius:50%;\n  background:var(--ink); color:#fff; border:none; cursor:pointer; font-size:11px;\n}\n#rj-ai-studio .ais-lib-play:hover{ background:var(--pink-dark); }\n#rj-ai-studio .ais-lib-play.playing{ background:var(--pink); }\n#rj-ai-studio .ais-lib-body{ flex:1; min-width:0; }\n#rj-ai-studio .ais-lib-name{\n  font-size:13px; font-weight:700; color:var(--ink);\n  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;\n}\n#rj-ai-studio .ais-lib-sub{ display:flex; gap:6px; flex-wrap:wrap; align-items:center; margin-top:2px; }\n#rj-ai-studio .ais-lib-dur{ font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; }\n#rj-ai-studio .ais-lib-pill{\n  font-size:10px; background:#F1F5F9; color:#64748B; border-radius:999px; padding:1px 7px; font-weight:600;\n}\n#rj-ai-studio .ais-lib-fav{\n  flex:none; background:none; border:none; cursor:pointer; font-size:15px;\n  color:var(--line); padding:2px 4px; line-height:1;\n}\n#rj-ai-studio .ais-lib-fav.on{ color:var(--pink); }\n#rj-ai-studio .ais-lib-use{ flex:none; display:flex; gap:0; }\n#rj-ai-studio .ais-lib-use button{\n  border:1.5px solid var(--pink); background:#fff; color:var(--pink-dark);\n  font-size:12px; font-weight:800; padding:6px 12px; cursor:pointer; font-family:inherit;\n}\n#rj-ai-studio .ais-lib-use button:first-child{ border-radius:999px 0 0 999px; }\n#rj-ai-studio .ais-lib-use button:last-child{ border-radius:0 999px 999px 0; border-left:none; padding:6px 9px; }\n#rj-ai-studio .ais-lib-use button:only-child{ border-radius:999px; }\n#rj-ai-studio .ais-lib-use button:hover{ background:var(--pink); color:#fff; }\n#rj-ai-studio .ais-lib-slots{\n  display:flex; gap:6px; flex-wrap:wrap; padding:8px 10px 2px; width:100%;\n  border-top:1px dashed var(--line); margin-top:8px;\n}\n#rj-ai-studio .ais-lib-row.open-slots{ flex-wrap:wrap; }\n#rj-ai-studio .ais-lib-more{\n  display:block; width:100%; margin-top:10px;\n  border:1.5px dashed var(--line-2,#CBD5E1); background:#fff; color:var(--text);\n  border-radius:10px; padding:10px; font-weight:700; font-size:13px; cursor:pointer; font-family:inherit;\n}\n#rj-ai-studio .ais-lib-more:hover{ border-color:var(--pink); color:var(--pink-dark); }\n#rj-ai-studio .ais-lib-none{ font-size:13px; color:var(--muted); padding:14px 2px; }\n\/* ===== RJ24 v2 (Aug 2026): the single music decision ===== *\/\n#rj-ai-studio .ais-musicmode{ display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:4px 0 16px; }\n#rj-ai-studio #pc-musicmode{ grid-template-columns:repeat(2,1fr); }\n@media (max-width:640px){ #rj-ai-studio .ais-musicmode{ grid-template-columns:1fr; } }\n#rj-ai-studio .ais-musicmode button{\n  border:2px solid var(--line); background:#fff; border-radius:14px; padding:14px 16px;\n  text-align:left; cursor:pointer; font-family:inherit; transition:border-color .15s, background .15s;\n}\n#rj-ai-studio .ais-musicmode button:hover{ border-color:#F9A8D4; }\n#rj-ai-studio .ais-musicmode button.active{ border-color:var(--pink); background:var(--pink-light); }\n#rj-ai-studio .ais-musicmode button b{ display:block; font-size:14.5px; color:var(--ink); }\n#rj-ai-studio .ais-musicmode button small{ display:block; font-size:12px; color:var(--muted); margin-top:3px; }\n#rj-ai-studio .ais-musicmode button.active small{ color:var(--pink-dark); }\n#rj-ai-studio .ais-mm-panel{ margin-top:4px; }\n\/* ===== RJ24 v2 (Aug 2026): what Say It Yourself actually is ===== *\/\n#rj-ai-studio .ais-sty-what{\n  display:flex; gap:16px; align-items:flex-start;\n  background:#FDF2F8; border:1px solid #FBCFE8; border-left:4px solid var(--pink);\n  border-radius:14px; padding:18px 20px; margin-bottom:24px;\n}\n#rj-ai-studio .ais-sty-what-ico{ font-size:26px; flex:none; line-height:1.2; }\n#rj-ai-studio .ais-sty-what b{ display:block; font-size:16px; color:var(--ink); margin-bottom:6px; }\n#rj-ai-studio .ais-sty-what p{ margin:0; font-size:13.5px; line-height:1.6; color:var(--text); }\n#rj-ai-studio .ais-sty-what-when{ margin-top:8px !important; padding-top:8px; border-top:1px dashed #FBCFE8; }\n#rj-ai-studio .ais-sty-what-when b{ display:inline; font-size:13.5px; color:var(--pink-dark); }\n@media (max-width:560px){ #rj-ai-studio .ais-sty-what{ flex-direction:column; gap:10px; padding:16px; } }\n\n\/* ===== RJ24 v2 (Aug 2026): the library picker on a phone =====\n   Radio people genuinely do this from a studio on their phone. The row wraps so\n   the name keeps its width and the Use control drops underneath, rather than\n   the name being crushed to make room for buttons. *\/\n@media (max-width:560px){\n  #rj-ai-studio .ais-lib-row{ flex-wrap:wrap; row-gap:8px; }\n  #rj-ai-studio .ais-lib-body{ flex:1 1 60%; }\n  #rj-ai-studio .ais-lib-use{ flex:1 1 100%; }\n  #rj-ai-studio .ais-lib-use button:first-child{ flex:1; border-radius:999px 0 0 999px; }\n  #rj-ai-studio .ais-lib-use button:only-child{ flex:1; border-radius:999px; }\n  #rj-ai-studio .ais-lib-name{ white-space:normal; }\n  #rj-ai-studio .ais-arr-actions{ flex-direction:column; align-items:stretch; }\n  #rj-ai-studio .ais-arr-free{ margin-left:0; text-align:center; }\n  #rj-ai-studio .ais-lib-bar{ flex-direction:column; }\n  #rj-ai-studio .ais-lib-search{ min-width:0; width:100%; }\n  \/* Chip rows scroll sideways instead of becoming a wall of wrapped pills. *\/\n  #rj-ai-studio .ais-lib-roles,\n  #rj-ai-studio .ais-lib-tags{ flex-wrap:nowrap; overflow-x:auto; padding-bottom:4px; }\n}\n\/* One slot should not sit in a three-column grid looking abandoned. *\/\n#rj-ai-studio #ais-sfx-picker .ais-arr,\n#rj-ai-studio #pc-sfx-picker .ais-arr{ grid-template-columns:1fr; }\n\n\/* ===== RJ24 v2 (Aug 2026): mix editor ===== *\/\n\/* ---------- the panel ---------- *\/\n#rj-ai-studio .ais-mix{\n  margin-top:16px; border:1px solid var(--line); border-radius:14px;\n  background:#0E1116; color:#E8EBEE; overflow:hidden;\n}\n#rj-ai-studio .ais-mix-head{\n  display:flex; align-items:center; gap:10px; flex-wrap:wrap;\n  padding:10px 12px; background:#151A21; border-bottom:1px solid #232A33;\n}\n#rj-ai-studio .ais-mix-title{\n  font-size:12px; font-weight:800; letter-spacing:.1em; text-transform:uppercase;\n  color:#94A3B8; margin-right:auto;\n}\n#rj-ai-studio .ais-mix-btn{\n  border:1px solid #2C333C; background:#1B2128; color:#E8EBEE;\n  border-radius:8px; padding:8px 12px; font-family:inherit; font-size:13px;\n  font-weight:700; cursor:pointer; min-height:36px;\n}\n#rj-ai-studio .ais-mix-btn:hover{ background:#232A33; border-color:#3A424C; }\n#rj-ai-studio .ais-mix-btn.on{ background:var(--pink); border-color:var(--pink); color:#fff; }\n#rj-ai-studio .ais-mix-btn:disabled{ opacity:.45; cursor:default; }\n#rj-ai-studio .ais-mix-btn-primary{ background:var(--pink); border-color:var(--pink); color:#fff; }\n#rj-ai-studio .ais-mix-btn-primary:hover{ background:var(--pink-dark); border-color:var(--pink-dark); }\n#rj-ai-studio .ais-mix-time{\n  font-variant-numeric:tabular-nums; font-size:12.5px; color:#94A3B8; min-width:92px; text-align:center;\n}\n\n\/* ---------- lanes ---------- *\/\n#rj-ai-studio .ais-mix-body{ position:relative; padding:8px 0 4px; overflow-x:auto; }\n#rj-ai-studio .ais-mix-ruler{\n  position:relative; height:22px; margin:0 10px 6px; border-bottom:1px solid #232A33;\n  cursor:pointer; touch-action:none;\n}\n#rj-ai-studio .ais-mix-tick{\n  position:absolute; top:0; bottom:0; width:1px; background:#232A33;\n}\n#rj-ai-studio .ais-mix-tick span{\n  position:absolute; top:2px; left:4px; font-size:10px; color:#5C6470;\n  font-variant-numeric:tabular-nums;\n}\n#rj-ai-studio .ais-mix-lane{\n  position:relative; height:64px; margin:0 10px 6px; border-radius:8px;\n  background:#141920; border:1px solid #232A33; touch-action:none;\n}\n#rj-ai-studio .ais-mix-clip{\n  \/* redundant - the lane above already governs the gesture - but the computed\n     value on the clip reading 'auto' invites someone to break this later *\/\n  touch-action:none;\n  position:absolute; top:0; bottom:0; border-radius:7px; overflow:hidden;\n  background:#1D2731; border:1px solid #3B4A5A; cursor:grab;\n}\n#rj-ai-studio .ais-mix-clip.dragging{ cursor:grabbing; border-color:var(--pink); }\n#rj-ai-studio .ais-mix-clip canvas{ display:block; width:100%; height:100%; }\n#rj-ai-studio .ais-mix-clip-name{\n  position:absolute; top:3px; left:8px; right:8px; font-size:11px; font-weight:700;\n  color:#CBD5E1; pointer-events:none; text-shadow:0 1px 2px #000;\n  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;\n}\n#rj-ai-studio .ais-mix-clip[data-part=\"voice\"]{ background:#1B2A38; border-color:#4A7FA6; }\n#rj-ai-studio .ais-mix-clip[data-part=\"bed\"]{ background:#231C33; border-color:#7C5CB0; }\n#rj-ai-studio .ais-mix-clip[data-part=\"open\"],\n#rj-ai-studio .ais-mix-clip[data-part=\"close\"]{ background:#2B2419; border-color:#A6803F; }\n\n\/* trim handles *\/\n#rj-ai-studio .ais-mix-trim{\n  position:absolute; top:0; bottom:0; width:14px; cursor:ew-resize;\n  background:linear-gradient(90deg, rgba(255,255,255,.14), transparent);\n}\n#rj-ai-studio .ais-mix-trim.r{ right:0; background:linear-gradient(270deg, rgba(255,255,255,.14), transparent); }\n#rj-ai-studio .ais-mix-trim.l{ left:0; }\n\/* fade handles \u2014 the little corner grips *\/\n#rj-ai-studio .ais-mix-fade{\n  position:absolute; top:2px; width:16px; height:16px; border-radius:50%;\n  background:#0E1116; border:2px solid #6EE7B7; cursor:ew-resize;\n}\n#rj-ai-studio .ais-mix-fade.l{ left:2px; }\n#rj-ai-studio .ais-mix-fade.r{ right:2px; }\n#rj-ai-studio .ais-mix-fadefill{ position:absolute; top:0; bottom:0; pointer-events:none; }\n\n\/* playhead *\/\n#rj-ai-studio .ais-mix-play{\n  position:absolute; top:0; bottom:0; width:2px; background:#F87171;\n  pointer-events:none; z-index:5;\n}\n#rj-ai-studio .ais-mix-play::before{\n  content:''; position:absolute; top:-4px; left:-5px;\n  border-left:6px solid transparent; border-right:6px solid transparent;\n  border-top:8px solid #F87171;\n}\n\n\/* level sliders *\/\n#rj-ai-studio .ais-mix-levels{\n  display:flex; gap:18px; flex-wrap:wrap; align-items:center;\n  padding:10px 14px; border-top:1px solid #232A33; background:#12171D;\n}\n#rj-ai-studio .ais-mix-lv{ display:flex; align-items:center; gap:10px; font-size:12.5px; color:#94A3B8; }\n#rj-ai-studio .ais-mix-lv b{ color:#E8EBEE; font-weight:700; min-width:118px; }\n#rj-ai-studio .ais-mix-lv input[type=range]{ width:130px; accent-color:var(--pink); }\n#rj-ai-studio .ais-mix-lv output{ font-variant-numeric:tabular-nums; min-width:34px; color:#CBD5E1; }\n#rj-ai-studio .ais-mix-hint{\n  padding:9px 14px; font-size:12px; line-height:1.55; color:#7C8794;\n  background:#0E1116; border-top:1px solid #232A33;\n}\n#rj-ai-studio .ais-mix-hint b{ color:#CBD5E1; }\n\n\/* phone *\/\n@media (max-width:640px){\n  \/* Break out of the result panel's 24px and the tool panel's 26px. Measured at\n     390px: 237px of usable timeline becomes 337px, nothing clips, and the page\n     still does not scroll sideways. *\/\n  #rj-ai-studio .ais-mix{ margin-left:-50px; margin-right:-50px; border-radius:12px; }\n  #rj-ai-studio .ais-mix-lane{ height:76px; }\n  #rj-ai-studio .ais-mix-trim{ width:20px; }\n  #rj-ai-studio .ais-mix-fade{ width:22px; height:22px; }\n  #rj-ai-studio .ais-mix-lv b{ min-width:0; }\n  #rj-ai-studio .ais-mix-lv input[type=range]{ width:100%; }\n  #rj-ai-studio .ais-mix-lv{ flex:1 1 100%; }\n  #rj-ai-studio .ais-mix-time{ min-width:0; }\n}\n.ais-mix-warn{padding:7px 14px;font-size:12.5px;font-weight:700;color:#FCD34D;\n  background:rgba(245,158,11,.12);border-top:1px solid rgba(245,158,11,.3);}\n.ais-mix-clip.overlap{box-shadow:0 0 0 2px #F59E0B inset;}\n.ais-segedit-cta{margin-top:14px;text-align:center;}\n.ais-segedit-note{margin:7px auto 0;max-width:430px;font-size:12.5px;line-height:1.5;color:var(--dim,#64748B);}\n.ais-segedit-note b{color:var(--text,#0F172A);}\n\/* ===== RJ24 v2 (Aug 2026): Seg Edit showcase ===== *\/\n#rj-ai-studio .ais-se{margin-top:34px;border-radius:22px;overflow:hidden;\n  background:linear-gradient(160deg,#141B2B 0%,#0B1220 58%,#0E0A16 100%);\n  box-shadow:0 30px 70px -34px rgba(0,0,0,.6);}\n#rj-ai-studio .ais-se-in{padding:38px 38px 34px;}\n#rj-ai-studio .ais-se-tag{display:inline-flex;align-items:center;gap:8px;\n  font-size:11.5px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;\n  color:#FBCFE8;background:rgba(236,72,153,.16);border:1px solid rgba(236,72,153,.4);\n  border-radius:999px;padding:6px 13px;}\n#rj-ai-studio .ais-se h3{font-family:'Anton',sans-serif;text-transform:uppercase;\n  font-size:clamp(28px,4.4vw,44px);line-height:.98;color:#fff;margin:16px 0 0;letter-spacing:-.5px;}\n#rj-ai-studio .ais-se h3 .pop{background:linear-gradient(180deg,#FBCFE8,#EC4899 70%);\n  -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}\n#rj-ai-studio .ais-se-sub{color:rgba(255,255,255,.8);font-size:16px;line-height:1.55;\n  margin:14px 0 0;max-width:56ch;}\n#rj-ai-studio .ais-se-sub b{color:#fff;}\n#rj-ai-studio .ais-se-shot{margin:26px 0 0;border-radius:14px;overflow:hidden;\n  border:1px solid rgba(255,255,255,.1);box-shadow:0 26px 60px -22px rgba(0,0,0,.85);}\n#rj-ai-studio .ais-se-shot img{display:block;width:100%;height:auto;}\n#rj-ai-studio .ais-se-cap{margin:10px 0 0;font-size:12.5px;color:rgba(255,255,255,.5);text-align:center;}\n#rj-ai-studio .ais-se-grid{margin-top:28px;display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}\n@media (max-width:760px){ #rj-ai-studio .ais-se-grid{grid-template-columns:1fr;}\n  #rj-ai-studio .ais-se-in{padding:26px 20px 24px;} }\n#rj-ai-studio .ais-se-card{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.09);\n  border-radius:13px;padding:16px 17px;}\n#rj-ai-studio .ais-se-card h4{margin:0 0 5px;font-size:14.5px;color:#fff;}\n#rj-ai-studio .ais-se-card p{margin:0;font-size:13px;line-height:1.5;color:rgba(255,255,255,.72);}\n#rj-ai-studio .ais-se-find{margin:24px 0 0;padding:14px 16px;border-radius:12px;\n  background:rgba(236,72,153,.13);border:1px solid rgba(236,72,153,.32);\n  font-size:13.5px;line-height:1.55;color:rgba(255,255,255,.9);}\n#rj-ai-studio .ais-se-find b{color:#FBCFE8;}\n\/* ---- volume envelope points (replaces the fade dots and the level sliders) ---- *\/\n#rj-ai-studio .ais-mix-pt{\n  position:absolute; width:13px; height:13px; margin:-7px 0 0 -7px; border-radius:3px;\n  background:#0E1116; border:2px solid #FBBF24; cursor:ns-resize; z-index:4;\n  box-shadow:0 1px 4px rgba(0,0,0,.6);\n}\n#rj-ai-studio .ais-mix-pt.end{ cursor:ns-resize; border-color:#FCD34D; border-radius:50%; }\n#rj-ai-studio .ais-mix-pt.on{ background:#FBBF24; }\n@media (max-width:640px){\n  #rj-ai-studio .ais-mix-pt{ width:19px; height:19px; margin:-10px 0 0 -10px; border-radius:4px; }\n}\n#rj-ai-studio .ais-mix-envhint{\n  padding:7px 14px; font-size:12px; color:rgba(255,255,255,.55);\n  border-top:1px solid rgba(255,255,255,.07);\n}\n#rj-ai-studio .ais-mix-envhint b{ color:#FCD34D; }\n\/* ---- the two big lists scroll themselves instead of the page ---- *\/\n#rj-ai-studio .ais-lib-list{\n  max-height:44vh; min-height:180px; overflow-y:auto; overscroll-behavior:contain;\n  padding-right:4px;\n}\n#rj-ai-studio .rj24-vp-v2-grid{\n  max-height:40vh; overflow-y:auto; overscroll-behavior:contain; padding-right:4px;\n}\n#rj-ai-studio .ais-lib-list::-webkit-scrollbar,\n#rj-ai-studio .rj24-vp-v2-grid::-webkit-scrollbar{ width:10px; }\n#rj-ai-studio .ais-lib-list::-webkit-scrollbar-thumb,\n#rj-ai-studio .rj24-vp-v2-grid::-webkit-scrollbar-thumb{\n  background:rgba(148,163,184,.45); border-radius:6px;\n}\n\n\/* ---- steps fold to a single line ---- *\/\n#rj-ai-studio .ais-stepbox > .ais-step-h{ cursor:pointer; user-select:none; }\n#rj-ai-studio .ais-step-caret{\n  margin-left:auto; font-size:12px; font-weight:800; color:var(--pink);\n  display:inline-flex; align-items:center; gap:5px; white-space:nowrap;\n}\n#rj-ai-studio .ais-stepbox > .ais-step-h{ display:flex; align-items:center; gap:9px; }\n#rj-ai-studio .ais-step-sum{\n  display:none; font-size:12.5px; font-weight:600; color:var(--dim,#64748B);\n  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:46%;\n}\n#rj-ai-studio .ais-stepbox.folded > .ais-step-h .ais-step-sum{ display:inline; }\n#rj-ai-studio .ais-stepbox.folded > *:not(.ais-step-h){ display:none !important; }\n#rj-ai-studio .ais-stepbox.folded{ padding-bottom:4px; }\n\/* ---- the tabs are the only navigation on a 4,700px page, so they follow you ---- *\/\n#rj-ai-studio .ais-tabs{\n  position:sticky; top:0; z-index:60; background:#fff;\n  padding-top:8px; padding-bottom:8px; margin-left:-4px; margin-right:-4px;\n  padding-left:4px; padding-right:4px;\n}\n#rj-ai-studio .ais-tabs.ais-stuck{\n  box-shadow:0 6px 18px -8px rgba(15,23,42,.28); border-bottom:1px solid #E2E8F0;\n}\n@media (max-width:760px){\n  #rj-ai-studio .ais-tabs{ overflow-x:auto; flex-wrap:nowrap; scrollbar-width:none; }\n  #rj-ai-studio .ais-tabs::-webkit-scrollbar{ display:none; }\n}\n\n\/* ---- the finished piece, carried in the bar that is already there ---- *\/\n#rjais-footer .ais-foot-out{ display:none; align-items:center; gap:8px; }\n#rjais-footer .ais-foot-out.on{ display:inline-flex; }\n#rjais-footer .ais-foot-out button{\n  background:#0F172A; color:#fff; border:0; border-radius:999px; cursor:pointer;\n  font-family:'Inter',sans-serif; font-weight:800; font-size:13.5px; padding:10px 16px;\n}\n#rjais-footer .ais-foot-out button.ghost{ background:#fff; color:#0F172A; border:1.5px solid #CBD5E1; }\n#rjais-footer .ais-foot-out button:hover{ filter:brightness(1.12); }\n@media (max-width:700px){\n  #rjais-footer .ais-foot-out button span.lbl{ display:none; }\n  #rjais-footer .ais-foot-out button{ padding:10px 13px; }\n}\n\/* ================= two columns: the form, and what you are making =================\n   Only ever applied to the panel currently on screen (.ais-live), because\n   switchTool sets display inline and a blanket !important would reveal every\n   hidden panel at once. Sung Jingles is deliberately not in this list. *\/\n@media (min-width:1180px){\n  #rj-ai-studio #ais-advert-panel.ais-live,\n  #rj-ai-studio #ais-sweeper-panel.ais-live,\n  #rj-ai-studio #ais-djdrop-panel.ais-live,\n  #rj-ai-studio #ais-podcast-panel.ais-live{\n    display:grid !important;\n    grid-template-columns:minmax(0,1fr) 400px;\n    column-gap:26px;\n    align-items:start;\n  }\n  #rj-ai-studio .ais-panel.ais-live > *{ grid-column:1; min-width:0; }\n\n  \/* the monitor rail *\/\n  #rj-ai-studio .ais-panel.ais-live > .ais-result{\n    grid-column:2; grid-row:1 \/ span 200;\n    position:sticky; top:104px; align-self:start;\n    margin-top:0;\n  }\n  \/* before anything exists, say what the column is for rather than leaving a hole *\/\n  #rj-ai-studio .ais-panel.ais-live::after{\n    content:\"\ud83c\udfa7  Your finished piece appears here \u2014 with Seg Edit, the player and the download.\";\n    grid-column:2; grid-row:1 \/ span 200;\n    position:sticky; top:104px; align-self:start;\n    display:block; padding:26px 22px; border-radius:14px;\n    border:2px dashed var(--line,#E2E8F0); background:var(--cream,#FBF7F4);\n    font-size:13.5px; line-height:1.6; color:var(--dim,#64748B); text-align:center;\n  }\n  #rj-ai-studio .ais-panel.ais-live.ais-has-result::after{ display:none; }\n\n  \/* a timeline in a 400px rail is not a timeline - Seg Edit takes the lot *\/\n  #rj-ai-studio .ais-panel.ais-live.ais-editing > .ais-result{\n    grid-column:1 \/ -1; grid-row:auto; position:static; margin-top:18px;\n  }\n  #rj-ai-studio .ais-panel.ais-live.ais-editing::after{ display:none; }\n\n  \/* 1000px is right for one column and cramped for two - it leaves 520px for a\n     step containing a browsable library. Widen only while a tool is open, so the\n     marketing section above keeps its reading measure. *\/\n  #rj-ai-studio.ais-tool-open{ max-width:1360px; }\n\n  \/* the library and the voices get a taller window now they have their own column *\/\n  #rj-ai-studio .ais-lib-list{ max-height:52vh; }\n  #rj-ai-studio .rj24-vp-v2-grid{ max-height:46vh; }\n}\n\n\/* ---- RJ24-EDIT (pass zv): in-page account panel -------------------\n   Shown over the studio when a Create button is pressed logged-out.\n   Deliberately plain: two fields and one button. Every extra field\n   here is asked for before the customer has heard anything, which is\n   the worst possible moment to ask for it. *\/\n#rj-ai-studio .ais-acct-veil{ position:fixed; inset:0; background:rgba(15,23,42,.55);\n  display:none; align-items:center; justify-content:center; padding:18px; z-index:9999; }\n#rj-ai-studio .ais-acct-veil.show{ display:flex; }\n#rj-ai-studio .ais-acct{ background:#fff; border-radius:18px; padding:26px 26px 22px;\n  width:100%; max-width:410px; box-shadow:0 24px 60px rgba(15,23,42,.28);\n  max-height:calc(100vh - 36px); overflow-y:auto; }\n#rj-ai-studio .ais-acct h3{ margin:0 0 6px; font-size:21px; color:var(--ink,#1E293B); line-height:1.25; }\n#rj-ai-studio .ais-acct .ais-acct-sub{ margin:0 0 6px; font-size:14px; color:var(--text,#475569); line-height:1.55; }\n#rj-ai-studio .ais-acct .ais-acct-nocard{ margin:0 0 18px; font-size:13px; font-weight:600;\n  color:var(--pink-dark,#DB2777); background:var(--pink-light,#FDF2F8);\n  border-radius:10px; padding:9px 12px; line-height:1.5; }\n#rj-ai-studio .ais-acct label{ display:block; font-size:13px; font-weight:600;\n  color:var(--ink,#1E293B); margin:0 0 5px; }\n#rj-ai-studio .ais-acct input[type=email], #rj-ai-studio .ais-acct input[type=password]{\n  width:100%; box-sizing:border-box; padding:12px 14px; font-size:16px;\n  border:1px solid var(--line,#E2E8F0); border-radius:11px; margin:0 0 14px;\n  background:#fff; color:var(--ink,#1E293B); }\n#rj-ai-studio .ais-acct input:focus{ outline:none; border-color:var(--pink,#EC4899);\n  box-shadow:0 0 0 3px var(--pink-light,#FDF2F8); }\n#rj-ai-studio .ais-acct-go{ width:100%; padding:14px; font-size:16px; font-weight:700;\n  color:#fff; background:var(--pink,#EC4899); border:0; border-radius:12px; cursor:pointer; }\n#rj-ai-studio .ais-acct-go:hover{ background:var(--pink-dark,#DB2777); }\n#rj-ai-studio .ais-acct-go[disabled]{ opacity:.6; cursor:default; }\n#rj-ai-studio .ais-acct-err{ display:none; margin:0 0 12px; font-size:13.5px; line-height:1.5;\n  color:#B91C1C; background:#FEF2F2; border-radius:10px; padding:10px 12px; }\n#rj-ai-studio .ais-acct-err.show{ display:block; }\n\/* Not an error - a returning customer being told something useful. Same\n   shape as the error box so the layout does not jump, the panel's own\n   pink rather than red so it does not read as a telling-off. *\/\n#rj-ai-studio .ais-acct-err.ais-acct-note{ color:var(--pink-dark,#DB2777);\n  background:var(--pink-light,#FDF2F8); }\n#rj-ai-studio .ais-acct-alt{ margin:14px 0 0; font-size:13.5px; color:var(--text,#475569); text-align:center; }\n#rj-ai-studio .ais-acct-alt a{ color:var(--text,#475569); font-size:13px;\n  text-decoration:underline; text-underline-offset:2px; }\n#rj-ai-studio .ais-acct-alt a:hover{ color:var(--pink-dark,#DB2777); }\n#rj-ai-studio .ais-acct-alt button{ background:none; border:0; padding:0; font:inherit;\n  color:var(--pink-dark,#DB2777); font-weight:600; cursor:pointer; text-decoration:underline; }\n#rj-ai-studio .ais-acct-close{ position:absolute; top:0; right:0; }\n#rj-ai-studio .ais-acct-x{ float:right; background:none; border:0; font-size:26px; line-height:1;\n  color:var(--muted,#94A3B8); cursor:pointer; padding:0 0 0 10px; margin:-6px -4px 0 0; }\n\/* The honeypot. Off-screen rather than display:none - some bots skip\n   hidden fields but not positioned ones. No label, never focusable. *\/\n#rj-ai-studio .ais-acct-hp{ position:absolute; left:-9999px; width:1px; height:1px;\n  opacity:0; pointer-events:none; }\n@media (prefers-reduced-motion: no-preference){\n  #rj-ai-studio .ais-acct{ animation:aisAcctIn .18s ease-out; }\n  @keyframes aisAcctIn{ from{ transform:translateY(8px); opacity:0; } to{ transform:none; opacity:1; } }\n}\n<\/style>\n<!-- end-css-marker -->\n\n<div id=\"rj-ai-studio\">\n\n  <!-- SLIM HEADER (shown to logged-in regulars; hidden for logged-out) -->\n  <!-- SLIM HEADER (shown to logged-in regulars; hidden for logged-out) -->\n  <div class=\"ais-slim\" id=\"ais-slim\" style=\"display:none;\">\n    <div class=\"ais-slim-left\"><span class=\"ais-slim-badge\">AI Studio<\/span> <span class=\"ais-slim-sub\">Radio imaging since 2014 &middot; 600+ stations<\/span><\/div>\n    <button type=\"button\" class=\"ais-slim-toggle\" id=\"ais-slim-toggle\">&#8505; What&rsquo;s this &amp; hear demos<\/button>\n  <\/div>\n\n  <!-- FULL SELL SECTION (always shown logged-out; collapsible for logged-in) -->\n  <div id=\"ais-sell\">\n\n    <div class=\"ais-hero2\">\n      <div class=\"ais-offer\"><span class=\"dot\"><\/span> Hear everything before you pay<\/div>\n      <h1>Station imaging<br><span class=\"pop\">in about a minute.<\/span><\/h1>\n      <p class=\"ais-sub\">Adverts, sweepers, DJ drops, podcast intros and <b>sung jingles<\/b> &mdash; written, voiced and produced while you wait. Pick a voice from <b>1,279<\/b>, type your line, and download a finished, royalty-free piece that&rsquo;s ready for air.<\/p>\n      <div class=\"ais-hero-cta\">\n        <a href=\"#ais-tools\" class=\"ais-btn-primary\">Start creating &rarr;<\/a>\n        <a href=\"#ais-demos\" class=\"ais-btn-ghost\">&#9654; Hear it first<\/a>\n      <\/div>\n      <p class=\"ais-nocard\">Preview every voice and every piece of music for free, before you spend a credit. Full commercial licence &mdash; yours to put on air.<\/p>\n    <\/div>\n\n    <div class=\"ais-proof\">\n      <div class=\"ais-proof-cell\"><div class=\"ais-proof-n\">1,279<\/div><div class=\"ais-proof-l\">AI voices &mdash; hear any of them <b>before<\/b> you spend a credit<\/div><\/div>\n      <div class=\"ais-proof-cell\"><div class=\"ais-proof-n\">6<\/div><div class=\"ais-proof-l\">tools: adverts, sweepers, drops, podcast, sung, say-it-yourself<\/div><\/div>\n      <div class=\"ais-proof-cell\"><div class=\"ais-proof-n\">2014<\/div><div class=\"ais-proof-l\">making radio imaging since &mdash; real studio, real people<\/div><\/div>\n      <div class=\"ais-proof-cell\"><div class=\"ais-proof-n\">600+<\/div><div class=\"ais-proof-l\">stations trust us with their sound<\/div><\/div>\n    <\/div>\n\n    <div class=\"ais-demos2\" id=\"ais-demos\">\n      <h3 class=\"ais-demos2-h\">Hear it before you decide<\/h3>\n      <p class=\"ais-demos2-s\">Every one of these was made in AI Studio, start to finish. Nothing was touched afterwards.<\/p>\n      <div class=\"ais-demo-grid\">\n        <div class=\"ais-demo\" data-src=\"https:\/\/radiojingles24.com\/wp-content\/uploads\/2026\/05\/advert-AI.mp3\"><span class=\"ais-demo-ico\">&#127897;&#65039;<\/span><div class=\"ais-demo-meta\"><b>Advert<\/b><small>Voiced + music bed<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">&#9654;<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/radiojingles24.com\/wp-content\/uploads\/2026\/05\/ai-sweeper.mp3\"><span class=\"ais-demo-ico\">&#128227;<\/span><div class=\"ais-demo-meta\"><b>Sweeper<\/b><small>Punchy station imaging<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">&#9654;<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/radiojingles24.com\/wp-content\/uploads\/2026\/05\/ai-drops.mp3\"><span class=\"ais-demo-ico\">&#127911;<\/span><div class=\"ais-demo-meta\"><b>DJ Drop<\/b><small>Presenter shout<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">&#9654;<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/radiojingles24.com\/wp-content\/uploads\/2026\/05\/aipodcast.mp3\"><span class=\"ais-demo-ico\">&#127916;<\/span><div class=\"ais-demo-meta\"><b>Podcast Intro<\/b><small>Intro \/ outro<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">&#9654;<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/radiojingles24.com\/wp-content\/uploads\/2026\/05\/AI-Sung.mp3\"><span class=\"ais-demo-ico\">&#127925;<\/span><div class=\"ais-demo-meta\"><b>Sung Jingle<\/b><small>Sung station logo<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">&#9654;<\/button><\/div>\n      <\/div>\n      <p class=\"ais-demos-note\">These are examples. Yours will be unique to your script, your voice choice and your station.<\/p>\n    <\/div>\n\n    <div class=\"ais-se\">\n      <div class=\"ais-se-in\">\n        <span class=\"ais-se-tag\">New &middot; Seg Edit<\/span>\n        <h3>Don&rsquo;t like the timing?<br><span class=\"pop\">Move it yourself.<\/span><\/h3>\n        <p class=\"ais-se-sub\">Every other AI jingle tool hands you a finished file and a\n          re&#8209;generate button &mdash; if the music sits wrong under your voice, your only\n          option is to spend again and hope. <b>We give you the timeline.<\/b> Slide the music off\n          the front of your read, trim a long ending, set the fades, and hear it straight away.<\/p>\n        <div class=\"ais-se-shot\">\n          <img loading=\"lazy\" decoding=\"async\" src=\"data:image\/png;base64,iVBORw0KGgoAAAANSUhEUgAAA34AAADrCAMAAAABtLRLAAABIFBMVEX08\/bi6PDb18i+zNifyOafyOWfxd+cx+WZxeTNue\/JtO7zvl37vyTzuSTyuCOXuNHIs+3GsO3erLK\/q+KUo7ilqazKnDahkL1+pcGBlah0i6Fdgp2AgKBKf6b4cnXsSJmJaTN6apBvXo9wXn58XLB2WnZeepFSd5JJfaRWboNIaYFjW3xcT3RBX3Y9T2FUQGQ9OU82OUc0LUMsQVMrOUYrMz0oLjgjLTcjKjMiNEMfLjwbKjgbKjcuJzsqIRgnITMjJC8jHDMjHDIgHy0bJzMbISkbISgbICgYISsVHyoVGiESHCYTGyYTGyUSGyUSGiMdGCocFigaFSUYEyMXEyQXEyMXEyIUGSAVGR0RGSETFR4VEh8OEhYOERYMEhkQDRg9jW94AAA6WElEQVR42u2dC0MTR\/fwiyu9aNuoEEmyeVICLTF7zy5YtjEGA4rFfzXgK32EJ\/L9v8V7zszsbTbZnSAKwXNaA2xOZmfOzm\/OXM5MvvsuliUSEpKvIN\/lZanq7R2RkJB8YdnzqjKAS01ij4TkaxHYzAC41COTkJB8Peml+NPI9ZGQfF0HqMW+j+gjIfna\/C1Rz5OE5Jr7n02yBAnJ15cmdT1JSK61+1klO5CQXIdUwfl5ZAYSkusQb4n6nrdGDnef7lxGnu4eLlCWp+bW\/+O3Lyd\/+Fdj63zOofe5RPX2dsju092Xry4jL+GTi5Plabn9kvAxAK\/E1lNyTvjdFvlz99XlZffPRcqynNvfvrxcka3lnBN+t8X3fQ59UC92FynL2dz+8RXw++OKbC3ZmfC7JeO+p68+T54eLlKW07n1f\/sa4l+RrbN2JvzI+V2T+\/ucLKdz+8dXwe+PK7J11s6E3+2Qpy8\/E7+XTxcpy+nc\/vZ15IpsnbUz4Xc7ZOfV58rOQmV55xrx27k6OxN+hB\/hR\/iREH6EHwnhR\/gRfiSE3zeJX6+uaVrVpCpN+Alx8vuxd514An23m1frOdmfhJ8qfo5WbfbaVS21D2m3AQK29fBnYwsvmfBLZFqTXQZe2bv4DDxU2mpE16eJJ95kqpDCEX7A42mwv5jwv+A6UzvCN6br7rLsOI3GLk96a5dlzEznxcncXby91eiKhJNC8ZtlypvOTzq9TNmTfN8m\/Lx6fbUR\/77KrGiuNlajB+s0cmqNVa63BT89pSxvVlGsz8bv0dKd+HWq3Fl6xH4uffdohsZ3382JX5PnHIpwBfhV2\/weWlxbd+urKLtHDvvZYPZFEc1eN\/qD\/cTH4qAS14mUci2qeJOpdplaAx8Z\/1Ckxf+C60ztCN+YodtdrePVbpQyZBeudTN56abvLt5urPKU0oVyRD1KypvJTzo9qew3A79q1ZtWs6vVzcvgV+9CDYCH2oVGcNVkWPVW8Xfh7La6abXd7u6RWce6Avar4+9KWW7y82fbs3KnaW01\/Bg8j6Yi9Oi7JYTrqvHTRM6bS9rn49eLvF6zmtRVVrPNuFpC4wf2d1gVPxLVmFdOsD7ntME\/ONsdJFWc4wcPsxGDkBKReAa\/abrQRpg8R9gI79Z5sgl+Dgd0Gn51nlJSqEzeMhLdMU4vXfYb0\/msLi1Vd\/M1W1tqXgI\/b5U5eShh96jXO2L4dVkTLEpe76XVPDQi6qwKjbqjht+dZlNjFdhqcifY5MDxP+W8q+D3SAAW\/8SrgN+jGLzUr0zr0aP5O5\/VpTv1Jlhc4Bflvi3y29ycCz8nqqM71eQBrHYdwaHjYA+\/sbqVIaQBl3dFFazHvmoe\/BrwQyAFaXmF+E3XhcyxDpHHcuHFfMX4OSlC0vihy8Q\/k0IleRPlnYqfSCMuezbf14wf1OZczeZVuLk5H36sb+kk\/RGGXzdiEKcKpqmxd1n3vNFVww\/qrgWVmDmTKi8DOkP+p7g4D36P8CAV6IPewZ+P+EW4DPvL2d\/4byl6C\/4CtaU74h14YwnfVMJvaWmTe2dWBMwo\/GgvJaXQrDnwi6mzEl+xFXfKxC9Y+0w2Jks6n0kXrpvHb9dJxIsAYAkI\/LqQZNKhbOCgnVf8KfjldCMkmHsTN5XxAzZTjUEaP\/i9x\/GLCpXtfHYz+ImRXpRequxSP\/Wa8YPH3pZqNuLH60MdL9WznaVZ+HWn4NdI4Wc2pql5dS+tq4YfOBL4p0FbUYVaXccMij\/bd5ayPedy\/ICoR+jq4B8n6dEdDhfj7zfx1p3v7nD82NUlBFb4yO+k3umsQSs2GXERotzDv+adOxa2fFWpT1qMX7vpMT6aTmrupWc2+ACs3u2iI0TE+CXh\/eByL8bPzOMnxmOpOirj52GFZtUY0jJ5ve7OwE\/WjZ20E5F11JPxwxvvRk1BFj\/0p6vpQqW8Hy\/vFPxEeqmyp\/NyE\/CL2l1Ws6EiIH6boj5wN1O\/Cu8n5tayah6bmJnL+zGpQ86aIofVJvMt\/M\/5O58AE\/YlkUIxFIw6n+JtvHznUTTee8RGgzg8RK1H38n0\/VbUbsS\/LS1ZDEjobGAnFClsxnwq4QfLDmi\/O8nUZxd7ZSmnxuY5dnF81cuN\/RCCnpr3y3Q+u8zFpsZzOe\/HhvOrU3WzPUM2+dMVfVrxApCZ0dAPU8ngd1Rf5X9GhVIa+\/H0burYT4jJa7am8R5cE8cmvDbXraJqkRn77XIX1\/US\/OoJV2ICIFLDqRdo\/BiTbNJYdey3xPL4SjQcvAzV+M\/58WN9TYSJyTT8Mp3PLH6\/fZebgJnd+eRDPaspmjV8AW\/Nc81kDvw2qww\/SCs19QJtPvcKq3zeHSppvVFPwch9AtfbOrrE2K\/L5lcd4V8aubHfUT3p7s3SFQSA32zw98EF4ofMCDIz0ttqxDMz+I7H8EsK5QgnF5fXaXTzY786xzxV9kbjZno\/\/LXKpi8sjdfmNlzIDqZmz3yCAdjMJ38QDL9dZjQ+5VLPquHUC1Nnz7bHSVXrfDI\/gf8w29bmJs5nRH+q4sdnNnHshvMqUR\/y0W9T8QMNMcKT8bvDx40q+GFfE\/v2mxy\/Td4dbUMHf6nJG7x5pl6eVqt3oJmr3qkmp++a9fTYD+tYr5F05rKT7\/Xu0eXww9Sd\/AR+hJ+Hd9zaLdKN1yAgu7zNdUTGo6mS3aPoamM3hR9603ShkkURUd6u6GbLUy+7N3ThIT\/24xWlicyxv9HZbKqu+61Gk1LxSNfhC0x4aatADX6pe8r4sZosRk+bbOy3lBpMaU0l\/JaSuRVp7IfULaXxu5O8JeEX8amCX5t3LjQx9rtTvcOcdhUaE5iNuVOfb+wHCw53qvD9D1qVDiBMHMDq0SJFvcgzn2n8ND69X12SV6kKol52C66lghl2FT86E79NHDpp3G2nZz7hz\/bSzIkiaXEv6lHmZj5Zj\/KRUuczUlJa92MdzWrcguRnPtvz4GdtwkLO3mZ7c5+wS4JdFg2\/zLpfgp8FVaUqanr1SmI+nasP1BENx6aV+VM96Cy33jd7Ne\/R1QSdpfuXAjZL9C3aFoVcH31bMZ\/ZqJesbMYzBhaFXNOOB5LrqMtazvkRfoQfydfCT6vShiPCj4T2+xF+JIQf4Uf4kRzRQYN00CDJER2zS8fs0jG7JEd0yDwdMk9CX7FCX7FCX7FCckRfMHZEXzBGQl+vSV+vSV+vSUJfLr2YXy5N+JGQXJ8QfiQkhB8JCeFHQkJC+JGQEH4kJCSEHwkJ4UdCQkL4kZAQfiQkJJ+D3y4JCcm1COA3IbkGWW+BrJMdvm0h\/K5LED+yAuFHRrgWGbRaA7IC4UdGuC73RzYg\/Ag\/wo+E8CP8SAg\/EsKPhPAj\/Ei+Kfx2FyvvC5ZdOb83Hb8FNy\/hR\/Ym\/Ci\/hB\/hR\/gRflQ\/CD\/KL+FH+BF+NxS\/s6evxJ9P9\/D16OnRjbb3q6dn\/E+e0Vd7e3s3Ob+JeTl+C2Pes72zRTLv2U2vvdPxO7J6jgdmnkwcz3OAQafnPL3B9vacnnU0OTqLMmr2er0bXD8S8\/YYfgtj3leOhfV5Ycx742vvdPy8ncnEOntlTZ5CMbynEweM79xce59Zk8mON3F6UUadyY3uHcXmPeP4LYx5d145iN+imPcVNBKQ4Rts3un4QYYnzNA9KAm0dGB\/9u+G2nsP2zpmYJ7RV5bjvLrB9SM2r+h8Lox5ebYXyLyTM+fsJpt3Bn5I3p542euxv29w87zXi\/LHM3rWO9uzjm5w\/YjMK\/BbGPPyar1A5mWe8AabdwZ+r5ihX6Hni7yfeYPrh8P+HZ2lMurt3eD6EZk35f0WwryJV1kQ83q9m23e6fgBcWfYh2N9DigNFGbvBjfP0NPHLOPYj2UUm+ub2j3KmDfyfotiXo7f4pjX8yY327zT8TtzHGeHt3IOm0RynJts78kO5I9NjYuMeo7l3eDBSWzeV06rBfldGPOCXbE6LIp590xm2BtsXlp2p2V3WnYn\/Ag\/Mi\/hR\/gRfoQf4Uf4kXkJP7I34UfVgfAj\/Mi8hB\/Zm\/Cj\/BJ+hB+Z91biR\/IVpdUiG3ybQgcNTuigQRI655PwIyH8SAg\/EsKP8CMh\/EgIPxLCj\/Aj+ZbwOzvco5lhWngg+RKyd3hWjN\/hjrvxO8nXkFaLbPCFZcPd\/\/cmydHL3cMi\/F56v7v+YDAYguzv74\/HJyfvP5B8EWm1yAZXLacnJ+MxVFyovlCLB777u\/fvzZKXL2fjd+hteEgf44\/wI\/wWTd5n8Rv4UKFvGn+Hs\/A72\/nd8\/0BeT\/C75bgB\/z9vn\/D+Ns9m4Hfoev6Pnk\/wu\/2eD\/fd90b7P4y+O1B15O8H+F3a8Z+yJ+3ccPwO9qbtePh9x55v6+O3+npKRnjy3m\/3u\/\/3rTeZyl+5P0u1fYmEv+dXI\/fFr+0WvzNXrN3OjuhU6Lzs8Z+C4kfeb\/L4VepAC0VLaJus46\/nZ5amtY+ZT80zTplKpoG+LE32\/ielM4qKp6jllYh\/Mj7kajgd16pnMOLds691nkdf4PLQFP1\/NTRtGZT03qnqFKvt1qbTOnCujjP+LrT07q22ta0C8s02\/hBsuxnjP3I+30zD\/+iUrk4v6hoFwhcpVcH54WInWuVqtaDa+bFhVmBK6h30WpV0b9VNrVNeBeQ23Tgz1Wmr11cNLXNi4uLuuYRfuT9SNTw0yoVYAioaba1ilfVTJ91OutNrYlYMonx0+BS3TQ18HHs3Ypmgh68rcHbplY\/P\/e16gXhR2M\/EkX86nXweYy0KlKI\/UpwewBjlXnF0\/Pzc47fOcOPcWZeaNUdrQ7UwYehs4n4nZta8xx6r94Fjf3I+5Godj4vuB9L46cx2VnVNs9PN6POJ+BXjfEDQAE0xK\/eRPzgc03NPO+R86OxH8nc+NW1OnQ+4Ue7h33PqmlCd9SLp14u+NSLyTBE\/HBOFD\/omRUT8BNTL+T85pDBe\/J+hB+fetmp4soBUrWJfU\/r4uIpzmTiOoLFxn5s4eEixg\/8XfOCv+9DB\/WCLTxcgPM7P5HpGx8vtpG88dXRlpKRd0Jjv1sqx2MlFeAGR3d8gqV3esF+Y3+f48uYv4PdUfzZauFV9g77G3\/z2czMKU8BL+zvS\/iNh\/vz5384vDHM7puDEwkc+YKKjGT8BtYJeb9bSt9wWK4ywuVynFfB2ZXz87E\/YPMs56en4mU8HDOm+GIf4sf1+duMyuPRCVso5MjCD0hFrnd+aV7yDtLLuZxyHzoe5Nqc4SUclwz+wPTlK\/4l2oaBJzHrmyfy2M8j76dcwwc3GL+x75c26qiSXjx\/4XpS6NjQG8hBZ6dTVVIJDV0\/V+8GZSSNc5i47rCUrRw33khOV76ilMpYxsSVryh0R3MO0rekUvsb6A\/T3s\/zvgXvd3IZcE6kB3nsezcYv5EnQ7AvV82h68mdIXdUrDJlx0OOtoGcLFzxy1zb0JNgG1rmQIZY9qGDnJtycyrylaFcxLz4sopvmvIVS8ZvMJJ7lp7cxPimdMXbcE+y+Lnuwnm\/0v1+70cDmT53fiBPBgO5YXWvxE0Ny69cIpWR503xdcX4+ZZVglKrNZJr78DKQWwN5HSlZMeu7E4GrgybtSHnxSqFeCDfGT90IuWl1Lo5FW9jQ+psepZsBvk+AJtMqLfhZyumu2G+kPDbXDjvZ8nebyjhdyK7qRPPKmNrSldTrs\/jfCovhnNzkodYodt4PCxtCoauK\/uXnFMC2ob+KF3vNqT661syfrmBnS\/T5puQSqZ3CakcS65NruG+7HgH+byYsiez3Bc5znMQSHfyTJnZ\/YHslOS+JuDnjr00ge6G3H6Ybq5n6R4PMh1Qd0PqfbobyF967Oe2F877bUjezx\/I+MmcnMBpGvIVt8wdHssqY9eUG\/VcDc85pfGUVAay33Lndm0j18p7j2xtHeb7hKDipb0OgLNhuV4WyAwWrVauH4kqw9E4mwokM8xcyfI3sGTTeSbkxR9nLmxkSYErWXKGOTfrmabn+tKHrAwDoCKT7+3nnJ1rZWGDAmWAA7+VBd3fkLujLqaSKTZc8V6kq6aF6Z6kvZ8F+IEdFgm\/dtb77XteDj9z9D5diY6hHRpk\/N2xa5X4OuBE6nDsQyr7xf5wnBsegspQAkduSEey35oylehKHbGhK8GGbJletrLKQ5oBPv50Q+Th3xvppNGT+Vn85BJ5jFk\/mlsfiVSAr9FgP1Yxvaw73MjjB58ZSBewUIMIJ0xlIEE8yFV5yPAwe2XDTBfckxRwqDfMpQJFGmUxgWRG2RtllhH8DXM8JZXMRW7uFLWsiLCoceL7MX7WYHNzofBrWhnvt+9Cg5KZmzuBtspL16Jj9pjcDFuSPzzOuyCgzU37ihGY0\/LSk3qAUrYlzfcJX1j4mWGaCpbKOHMlWyFGU7qNUl6QJNP1hpInSyeLvm48TJPs81rF0zkGB8bBwexEaXsMx3EKP+iwjjI9VsGsOxiPsSoPklSYR0YG2RUr62a98cAfS5UVPjPi0\/ojcQGuMBc3hPlKTtLwQwwkzIh40MCNkilNkYrPZjeZGr8CPjFCMMcjjOMGUP\/HMm1weCW2GAN45RcglcHoJFHJtCiQrH\/ipa+IVAYwNj75wHqhgmIP9E4++DAONMUD8CwXvMYG4Gc22+1m21so\/JptK+39oDd34vkp\/I5ZMa14IubDmJfbS4Z8YytbP7ivy4wIR2aqUTwZ7YsLQDH+wa8wlLJeagzvjDP44Wf41fEAGvcolVEEMqKU4Q\/81nia30opcZLSLYovqPDxJrjAjjyaVpS9fXBT4kNcD+D03fQFqLF+5MncgVg7APzabTOeoRnEKqlUrPQFH\/20UHFhVLPPSOK+LkplmNDGLrtmJhUT8wIPJyIJ2rQhW7rwzPaGFXtAvJpKxXUtE04BApX4gof96iSV2KvCextRj3SY0MbAcPHFTCXr+fg0IkJPPNGzdTfamEqUpJdKBeGHhsLzN9rJlYEJa34bKytNfmHTH5htbzhYW1lD\/hYKv3a7bWEF8fjYD2uvZYml4XGMH3sCMEsKz2sYXcCBARsTcSAhkWMx7zF2easUDQT9iDa4DLUP5xuGG6lURjgByNN1\/f3jwTBykFB3RJWH5v5DRBtUG2ikIf1xdAF7TQM20zEQqYxHwlUNLeZKBDjopvwoFahTkBd3mDBggRV8WHTwm2tNfNwW1OUhYuK319bYBfgQ1E1kZS16+pGAinSlyS\/A\/dwRrJ63WiIVKDVSgbW8mf\/MWntqKiDAIjpIkYrJ84LElaZibkR3jhB0myz\/PBEY07nuRv7OK7NTQXcFvJhMxWSJYCrWRt4K2VTMdCqD9y6kYom8QGkgFaw7G2sr+bxksmI1H9y7J65BHW7D6Hftp58ewN0XCT9sMECgnfPAT+yPzbU1XjlQLKy4TbmaSQY2LTMyDZu1A+r2eX3ARwupIIv5atbOPmzTiisrVO4hjEE\/DPmNWE31WIcRUlkrrPI8FV43ARxcVRvAlXZUzRg45lp5XtbuPVxJtMAMzRW80k5Xh4xKfGW2ittqrT2EC1NU2qmsYKVqtmcmCx3KtZV7uVRWsnlZY1fa2QtrIl2TNY5rKyvZGwkVKRU5LykVSMVrTsnLPSkvD+FGaxsFqYCK9JnmQynZjbUH0hW4cA9vtYbFaqIbWfkJZWWR8Lt\/\/xHmn2Uf\/SA8lLX7v\/zM5ZfH0FStPHiAz6kZPwaoIA\/QfPGF9sq9jIppMRV8cikV\/FCSClSzB+z551MRH7IwlYf8Ru1Y5SG\/UaqyMpXMjR7OSCVdn6W8SDfCOoTXVtjTRWEX2OcieShfKVVptbgKu1n6M\/dyN0on83B6XlZyNypSWZmhcm\/KZwpUFG70oPRGD3OFzl3JpzJLBeUnzh3\/8dNi4Xf\/\/srKPZZ5tDw85vs\/J3IfLP4gLifgAf\/fSwsytJL5W9Lgn1lJX7j3YMpnMlewskkXHk69ksmLpPKQYZb9TPaK+Lv4RlcnrdY9kiuWBwl+Py0gfs\/XGX+CsAfY6P7883Px9vOffwZv+JDkaqTVIht8AXkg\/EOEn7tA+E0mPeh\/gotL4xe\/z\/Aj\/gi\/G47fg8XFb7LO3B\/0Pld4vz+L31qE5hydT6n3Wd75lK8odD7v3SvvfK6UdD6n9mlXqPO5aLK4nU\/sYt6\/v54SN4OfZa5B85KauoDZmRlTLzB1lZmdmTr1wq\/AbOn0VNZS0yjt3KQJm1dZS6ZecIZyxtRLe2NmKs2VzIfMJBVp6iUzjSJPmhRMvazMnHp5KM+rJFMvK9kphoczU5k2X6M89XKvdOolnZfcjeJU1lZmzddE45gpkyb52ZmVz5h6eSCP\/VYWE7\/J\/fuPfxGTnb\/8+vhxBj\/Xa\/NVBYwDGeFi2rHFJ40tly2JYuSjtCaTW5vAVZoklQEutI9MsZCGKbgmW1VYKVtVSBYeLNjaBWEqYm0C14+tKJXSVQUzWbGDRT1IZcAXokyLrefhCoe08KCwqsAWArFtKJiDx4WHqasK8RVcFZm+8FC2HnAvl5e8ylp+nWGKyka0NuH6zYdTFx7acl7yhY4vRKsKsxce+NpEfuHhXuoz8LhnLTysyAsPDxbb+61n8Ls45QEIXhx6fYwhWrimDsFGEBHq8agXsRq+MYCI99EgWafd8NlmARH1MjgZivAUFuOCC+W4RYFtdhglqVgQBeC98JJURIwxX1OHFd9oMzoGnUFsCKQAK8Ds6sCK1+4gTgRiy5JUTIz4GoioZoxdjPaRD1224uuxTe5sud5rrhRDjCvQkQqsY+PBE75YTYaleosvMMqNUKvVzC8wxioYSDaK19QxFb7YWbpMyVbDk7zgdgmRiolhAywvVn5NvS0la8YxAfBgTtjGWounwtpYDPecloq8pp6kAvuMMPQM2m++vg+Piq3fWu2CyIJ4ZX4lSoWHmUGQyxp\/B1PBJgYJTS27g7Bl92azt2hjv8cDFvfpQ+AZxB1I+LlYF9wPcdg1BIEmEWXs6nEUZnsMASO4P1DwaI2OoyhJHlHmZ4JAzezuII4fxGGPIypEVN8wVmMhnunANIhLGzL0MlGgLBIaUUIqori04VgE07ArZjpIbgz7aD5Eb7NTXvx0pBSUHkJs0qFTEHMWxaV5o4GIm8RILwshxuYAYsGG6YgyDAexIOolWT33B14cdAbtQrQdFjPnYqTmAELMMAj0gyflxUvlBUyKnxPRmu4wir5kkdwmRJSxtzF+MxMSx1JJksXgn+FJFOgFrWcUURYFYbP4Ft9LottMHiMHXZB0Kh5uKxKxmbDHSMTM7vO2cMyCyT7Ajpp0KhBvA2VIBfCBcTESLWqSTizYisT24pps3wTkEjfZ+gBkKjBG4Nf8aQ3c4ILhBzOf67hLkUdeQy8ui9857ipOY5LfbnuCu7PQq0WIjl3+UDO0uZkN78c+Ro3uS1HZmd2nQ+Zm03t0XuBjHAyLNkVA4HY+KtvMXIEq42YOTTj2TG9aWCjr4fr8VJcoLjTODyM0vfMHo7IZdeMoTFW4WY+BNsSgM9GgeCwVjLJmEFjijzh8fSAaILbvj3MCgYEYcDYeHX+Id0X4Ual4KgNpU4QHvMQ7B6PI0SEGnLGrUZx2Np46sy3QYwBDeC2GZuKLiPl0YVvXiO81sqalwoJ795MIa+hTwB\/R3xF+Lts3zNiK0o33Cro8wO\/4Q7zpD4Kq3\/uwT2KAeyVG\/+9DKiwUhvkQt2WaLHRksfDDdb\/HjD6OH1jg19S636+An3ma2XD0fiRvWns\/lLaOIX5e5kOwMU\/eEShvWgMIhsdptjDm08+4tpG8s+kkdyTSiS9vlHU3TL94L21+tzjzofix+MQzTpuXuWBmdvexfUzjzN49K2ILI8THHwR+JmMx2QeU2d33AeOjpe22gq0EJZN7+MyFzEYEtpmPoZreguTiPt0skKiWosL0hqOT7G6i8Ym8swnZwqvj0UlC2346lcx+vw+5zfl4Zxdjr2O2OH7AYnToBKbiY6czPoXCc6W9wFYSRby\/D\/uChwNOX3PBol4EfRF+w\/VU1Mv6xfnAPZVOnyg\/2MWX95LldwCeyMfnwbBQdmTy3s5x+QExJ+P8HsFByTER+WMF0VlnPCbbpeSltwrhbvfM5\/I3YjtyU5+BHQ8bPN3I16GKN8oclOTJTQHuacTNDtldd5kdU7iXNnN0yzh\/ZAULHs9syd3I7dGV97Lnz5CR9\/ViKlbmKDNIRTomKX+qBaQyHqWPd\/Gm7MhNwcibyJMpWwKBWRga7u\/7PcCPdUKb7kLh93hd0Md33Xru+Hj9VwHfr48Bv9Fg7lOXc+fD5I+smALOftnediDU\/zD\/iWr+Jc6jgFtnmcxtMR9Ybu6QoWHZUUsu7v\/LDlW9snMFx7lTy2DHSJYkf8pBLvKxT7h9MeNVYY7shUSo\/NSG\/jiXl1HubInRftZByqckDUbyOZ9m3h+6x36aWjjZRf6QfEH40OOTIex2xw1zsOsI6DMXarvtIIJP4Acd+vGx2HH7\/nwd+LuSL1s9GQ0ucdpfzmOOxldyiOdg\/nM+cz3W\/dxhaCP5uD\/5vDTEzys7Ui3TZZx+zC6bMM70T93cIUnyESzj3ElnvumXnXQ2HpWeh+vLtOUPQ9svP1R3sLH\/IXPY2WBDftIj+TA0H8+aALSTwyYQv7a7UPjhyVC9VOdz+GIE\/B0fn5ycwMvz9cfPz6\/rOwcu4+uU8LsMxDnajsvPlxoPRzJ+uVPLBpc4mG0on\/w5VDgKVKZ6yoDXu0xeci7\/MmfKj3Ie0z0uRxYP+sye9dJetLNePK\/XE\/yJMydGo3Eiz399fHFd3\/hxNb7uyk4FHnw2xK2Wlzv99jJFzOPnjy6TigyOf4nT7Pf9sqPPlPCTXVuuqznlM7ljdt22O+j5C4YfApjG78UoJcfrv6xffCC5nMvM4Te+kgZlPHghX7gMfjKzCl3NKfidfBFzj8qPWh\/58jccue3eop3zOQW\/PH+ZLyInuTx+V5NOfng4Or6CVG62SKfzT\/t+Pzxmd7Do+AF\/KRk9f8yGfwTgzcHvG5TTvOS\/3RZWfm6D98vIPhv+8aOXiD\/C73rpy1XDNH6+e\/u8H3Q\/f4XVPyGEH+F3XfidJzIDP\/\/WeT+YBz15HMXA\/LrOS071gfD72vSdnyfRIKlquODf7V7i\/WDyZZwJQiP8CL\/rwW96NZzy7bb+7fF+uAiYCcFm7Q5VCMLvK+N3fj69Gk75bnf\/Nnm\/8bG0AYnwI\/yuAb+L6dUwh9\/OLfN+hB\/hd3Pxy439wP\/dYu\/3nOTS0mqRDS4vat5v\/fH9+7fK+51kyv348a+\/\/ExyKWm1yAaXk9wJYM8vQM5Ps\/jBf4\/x1PaFwW+PndBR7P2y+K2TXFpaLbLB5UXyAo\/h0nPG4LHADwToW39+tHHD6Dvam4HfIRwNNJ\/3O38PG5FILiWtFtngknLy\/lzyAo9\/FRvC158\/Pzs52d8D+uDglN5k4rs3DL+XhzPwO9v53Z1v7HcMpwS8ILmUtFpkg8tJrhqevz8\/f\/48hvBXcIW9w8PH99cnk6Pf929a3\/NsBn6TQ9jmXzbzmVlwIfwIv+vBL18N4eLpe4Rw\/TH\/Orz7959P\/mt5N9j5SfhNXnq\/W16x98uEGxB+hN\/14DejGg7516I\/7QGFgJ\/7+42j7+VkNn6Twx1343eSryGtFtngC8uGe7N6nkcvdw8nRfhNzg73dkm+hrRaZINvTPYOzybF+JF8LWm1yAbfuhB+hB8J4Uf4kRB+JIQfCeFH+JEQfiSEH8nXw48WHmjhgeS6Fh4Od18elS0eHjjGk\/+QfK60WmSDnDwxnIN\/b62ULbu\/fFmeRvDE2d55+vTpn7u7BwevX7\/++80\/\/0cyv7RaZAOUv\/+GSnRwsLv7J1SqnW3nSfDvLZaioLNDFfqM3s5OBr+\/qQoRfpeXNzF+wB9UrZ5xu\/mbGXJ9tlv+6YMnwQ7hR\/h9Ke8HEjw5uM38zdxwpOL8HGeH8CP8vqD329lxnG\/F\/WUPmzgq\/7CxTfgRfl\/U++1sG7cZv5mHTSj0Pf99Al\/\/jhbavj78DhahUm1Pv\/zP23++OH7G9ZU6DD\/T+z19ioeTPfn3Vvc+PwO\/\/8DZUY+fPnWCK8dv+\/qqV\/iZn\/\/nLQh\/iQozPZNve83e2y+M30E3vDb87OAzvd9TdjjZfwi\/Wfg9X0f+vgR+B9dWvZzPTPFtT9N6b9\/hS5Ridyqm79pa+90\/Xxa\/bWX7KLOyrZpi1\/gs7\/fnn+xwsgnhNxO\/yaQHJnKcp7tq637KNdtW1AyvHr\/u9mfi97Gt1T82tebHd+\/eoRN8axgHb9\/i7\/8wp8gE\/nx3Zp2949fftlrv3v7zBVz0dje46nZsO1DrvRx0u5\/j\/f7c5YeTEX5F+E3W7z8G\/NS834Fye6haGZSr1xz4BZ+L36eK5mjaJ0vTtOa7rtY1Klq3rlU1YK2q9d7Vta6Jb33c1DYBU00z3zmtlrb67u3VO7Wga6uNn7e7jmrvQCFF7G6H3e7BZ3i\/3V12OBnhV4zf8\/v3d3YOD4+O\/vvfs\/\/9bzL5VCCvnYNPpXLw+tOnjyqKnz6CsvPXp6sTvPVrJ\/jcZCYeMrUD\/6qaaWrm9qpmAn7tyadJW2tOKlrylqlVAcVJpdWqa031O4ROWK70+jUqKpTGeY2GVC12oKB44HyEf5iuktmTFCeT\/\/3v7L\/\/PTqCGoWHkxF+JfhNYPT3y1c8XP7xAsh6Vauyf+tatcl+bVY1OPMO3tE0cY39YJdBtFYL1BegZF\/v7Hg8G3BC+Cl4P8JPxq+ptfP4RWBq61n8HnP8qoRf9qsbyPupjf16vZcvX76C7sK\/\/56dvZku2wG+QH\/kTZk44Zs3obNdqveX4xxAisGbq5MgwFtDsip5PJh954\/QrTwTPUxLq25p2Pn89O7Nm7cfcUD4KdP57MIF6HxuaeZHtUw6r187KobE0jgKpfkLbRioFPsNf4YK9gEDhfiiIlCa2JSfzmAp+vDw1SuoUDT2U5v57Ha3QcIw\/AukP10MA17sTifol0jQsVHR6JcrQmJGpxP2r0zwrip5DFExmKn37ADmW17\/3QbS6u\/eVuEVp17+PsB33sCcy99v2FsfQetjHX7rvrFg6kXrHTxTyCMWG\/6pGBIyqaLooA0NBUVuo07HKbcP6NgKiv0gYCWKHyLWIKhIWJ92aOZTad3P2dpySvHrYGXgzzgsrzUMvzAoqzWBYq0J1WoWA1ql1gRYX4LZTcTB3x\/\/Pjh4+2nn08e\/4dft7T\/fvPv4+hl75+zTu+itNx\/f\/P0Rfnvz+k2rtR2EgUpLYif4BWWc9JUUWYOjQpUyfnBbG5O1S1NzBH5xDhP8tndo3U8h6mUH8AP35wQMv9ntYdTEBk5JZWCconspU4R6oIZfoIyfgSk6ZZ9gnDqz8Xt28Prg2bODv\/8GCA9eB7btHBwE28\/4OzCvfhC9xX7D11bLtreVsmnE+AVGGScRp8U2whIHnCojVLBRec8kMqRRano7xo8r\/pXGj6JeyvB7\/Ie9bW\/pW9vbdlDk\/biJ2YttlFQvQ\/jKUkWoMKzWlFbbQK1htzF\/vNbYZfixztXMyvrs2TN8OYDuZPAM3LT97JkRRO9Eb3HBX\/rPWi1I0g4UvY+osIX97jDCzynoJkeGtFHRhs8Eivg5QUnHJMLPKHmIDtPGfowjez+I58eYT8JvJn49o9t1DF3Xg23bLsUv7HCHFZZUBt5xKn1yUGFCVhmMsubYNpTxM3itKe5g2aJ6KeBiOFy7JEXEzzBU+tFoQ6fD+Q9KOBGKQfG9sdSs6GWcJvgFxf1KO8GvxFXygT5rSA3Z+zEAd3YIv5n4Qb9zi+HnbOtGGX6ih1NSa5M+U7miEYiX4ErwMzA1Ub0MVfyc4kEnNA22QorK+DE3ZYt2rBQ\/u8NHYUaZIXmx7fJhHQKND8goMyTeusNHyYUD\/SBgmYxSzOIH0iP8ZuJnA3l2t1bTnaAYP2ZiR6+xbk5ZrYGnW2EMGiVuwHA6Nd0wSicDDF11VgGkrncCW6V6QaOD2SisXkFjC7R1JfzUhrHcV4Eh+8XFFooV3eD9+UJFkFqponiQus6wDkrsExmy5GkbtmPUa3EbmvF+PSaE30z8DL2ud7cqdxuAVqcYP1037PqybgRltQZqNSo6JfgFLEV9udYpnwzQ9VBp5gVqVqezXGHOICysXnVQrFScfkl\/LXAqtb5Rqehl6xkQdKY29wiZhOwJQxqFboor1o2S1RlebDSk0VHBT9gnKGzHdGYf5qed4nk2EFA0IsUsfkEQeITfTPy6tbv1ra3v7zYABN0pwM\/oQIUxass1wy6jCnQixdnPOBSKdagM5X6jXg+U8KtBYvXlZb0sxU4d77q8vKWAX6VvQIpBiYtutfCuCvg5sSFL8LOFIStG4SohzuBWaljsSkdlUjO2uFNmSH15ubRnwrv7QtGQvV8QEH7F+FXu1vTG3buVLgwAO7Pwg4lHfGpo54pR0sTaqGhU4MUueMaBEdisHtRU8AtqtXL8nNDBehDjN7PWhI7RqXDFer9wWgNmeSF\/fVCsFRbbsRG\/msI6WSDss8ztM9uQYZ\/bp4K+qsiQndiQ5a0OAyZSnHlv7HGmDVn0tJmXRPuwhyh7P8KvBD8dyNNrd+9+D8+vthXMcH4GjNGwHkB7uAwOqzb7gQBV7PEuY4MII0VjZtPuGLFiBxRnc4ratYpTujbB6iHUgxqrDDgSKmixhWKl36\/oM5mBe9uQP0wWvWptZtWGkQ\/gVyn3PZBJaJmWDTRkB+0TFCgm9ukoGLLCFfUyB4yKhfaBSdHYkJVCQzoB9okYfugs61ianPfzCL\/Z+AF5dxsVfNHvfq\/bGe8XZsb2WA\/qy7xG6DNCX0IDKkOiWMFxSz8VCoK\/haGIocF6WENFfNAziWbNOQwu7OVp9Sq0wyijMOtgsApTwRd0McwjRhEzydKi0REVBrSXIbu1SDE3BwoLA8hJo5YqNjdImInEQccI+EGxt0qnZtP2qS2zuZ8oeESmSrZPmJgv6KeLnSjWuWJQNOG1LOzTmeabeYqdxJA6U+QpMiMFcS5sZsiKrgv7wGwb9CwQPztg7MH\/jkP4FeCH5NW+xxcAsWb85XRxGVXEdwZiZCQq7HIN7YwPugadUZxk51FWAf7qCEWjgY+XKer4+AK+wmRgOIaDigYuzxp8xMAVK6hoAEr4eDssMRa8gcTYNbjXll4BRWfKgq+BiTti6ddgFYalyHxBwN9mOWXzlriIwCfeWf4qiFYDbo9ltUU3NGSLBwGraQALS62SFNvG\/LH5xdDgpQl464T4wQRycdxOZB9uyA4vtmH0o2nVdBth1JdTpWGGhHuFzD4GN7bDw1M6kiJfRnWwCLZh96P1cJEPlizrqgYs8pUH\/9ioxArHShMZkjUUulhCYsVm2iGaK+SGhEcT2aeOayndv\/5ytjrAn77lBI6+RfgV4Ifk4b+7lQr2QIM40oItT4UY7wEmrbGeCK+H+FgqW\/jYQ3h8LHaDrYrBC6wi1IyMIvRAcV0vxBV2phM6\/A71CvMAXBFf6rYdp8NfoOWEKU+YHjGWUWF5KzflHQYscR4QoEMzvBwJS9ZAJIO4SEwR1vn1Wi2liBWNpSMi6gwsFwsIgE5dzahkFI2UaYxoZdrBfjvDD2piv2B4iDMakiGZfXjkK7cPH2jDXGZNlxWZJfEltIUhsXB62pCMajuAIIq+ExebYZu41VpUGjY3FvSDlCKmC7euS\/Zhk0RhrMPK7vBqURHGTttnu7Ol29tdXe8G+EL4zcbvbiTI4HJtq16BSQZdvHSiF+Eq4poNLTfq4EvHBtekA3gwhurkFSuoo7MXWOvCz+AL6yXGilzbQCwQYFi2AJ0AhpiNfhA3w5Cy5PwqNdvmiVXYDGouxU4n9XZSLpGzVCZ11LEDVLSxSFGe41tHio04sUinxhWXK4gfG0vO6kRD9rtZ+1Qi+3A72zrYENawDT6ay5VG3LBSN0oM2cEHoidlr6VHt6kGBRNr9LtZI3Wy9lmO7SN00nUjZ0hmny6+6EajUmlAdao0CD8F\/JC+5R8rP\/zwY6X244\/yiyw5nYp4yWtmdH5MXhQUK3pFvMEUjIxUhGIlfsmlmL1hQWmSdJIiVVQVeYqt1pQ8prNbqTUqs1JM27BSZsh0kWYrpnWQmMRs00qTLdccxc4r\/lCp\/LC8\/H3t7vLd73Fm4Xub8FPD74cfZPxUYPlxHqqUYeEvmWcs48ffnpOqaYo\/Vqa1KNMUazMVOX4\/zqIP0KupUVUpNKQqLLWMTgq\/2iWpUjbkD5Ua4oeTCvyF8FPDD+ZeWI\/GEb2MCi4d4Av2mbLdkVrU78ReWC1WzHeFmGL8Nrx0+cu0zhW8kyQG0V6VLVjwg1FhZWrnsws9rLjjxCZTpBSX9a5IkfdSYfqIZXc5JzrrdrHEHKnPnVWsG5F94s6nsI\/ofM4c+wV63QmmFLtmiN4upIMdeABE143O9M6nsE8nMmRtmuIyTpzYaBphn26m89lNK0JPlnfgk168w0oj2afDTZzugXL7SIr1uMur6w3xQp3PkpnPu9HUy3JnW0wEGGI7EEYSBTAo4WPsWvyi49CebdZz+BwEmzYIo6mF5XhGRQ\/seOdmNLfSYVEVei0ZILKpF0dMLQR8s2c\/wEoTwNqAPhU\/h09WdKIpgdzUS41tp2DxwHyrhniBWY1EEUtTsWFKNAoTN\/hkU3pqIVZc5imGUbHjsoupl+XZUy9sQjXMTL2w6U+dzd6EhpjWMPikvhNNvYiRNrNPomini11jUy8ik6gYhHyqFx+I2FuYmvp0ain7YDBbGD1tMT9l5OxTM7LTM3wOqw\/VQs\/ah0+9bEMYv25jMLGDL1uEX\/G6X60SLTzYfzl8Bj4QywnsNxYbjc+Y+SGcNqyxqWqxX5y9wDyhWFLT44l1sZ6As6Opl2iFIqu4jPXAiBbAnGSubqsGkwPQ0MIambwmjmt6bGI9ngaviCq4zBYebLYBIDR4kdgWfKcfT6xzoOq1eDkvxJlWXJxg5Qp5yHMtrViz+eJEUmxedkNh4SFeQNXjdozbJ1oSCcTqTBSNnrYPqHd4plhp2FEQHBs7o8iKHTdPAV8h4BZP3DCzDy82bxT4g3biRYW0IbERqNupNQdDFDuMFfnCQ73OFGG16q9tYws20Bh6dxtnPh3CbzZ+dVxxZ2vvEHkGy+7bdphftkW7d8SzWMZZaezN4Mpr6GS10W11hM5yHZfd7Wh5OPsCj87GOTihCMvKnRmrxbwhr9W6y4YclRUnZocC\/ZqogjW2WoysZ+8a8BVtO16fX4aGvuaEU1fpWP3eiv29zitskk4Q\/4b8sWV3vVu67B50BMvMPmjIZLEvMQAClrJPHQ0p7uVEL7xI0bK7nlp2TyUWyo8z5Iq6tOweZtuIyJDLGUMG8f2DpDEBXxnZBxUxcAqW3OF\/B\/8ZtOxehB+Ge+p1fNFrjS1neswnHPXKJqxZO6sXRF9BrQlRUeeKeqU0qCpWtIsCNexGw9HtMs\/iYP7qAj\/oGIUz+4HYn42Cqiozz2dAt9CNWh2okvWZ2w4Cjl+nLI\/YoU7sYxTG2oVOypC1mfYJ+DIFK3Ydi12WBaMs6Ax6FIaejt6baUhYAdRZpBsLoatzRQo6mwe\/CpDHGMRu+nbBhiNsWDtRbG0w++AdQ+eRwstMMSwIKdb5NMEyCykuqjVGAHuCy3YThDxFDOVarscBwLM2W1SwrvJIaqcg7JnN\/USKs0sTBoFiyDVb966J4NkCQ4a8m5gYcqYiDEAjRRYgbSuEXNcLFYEZZki9zJDgt416ny0V1nBo2ulTzOecIdf1u3Ud4q5xFk\/fKthwBA6jjv1FNgVQtPsMFTtsQ41R8OSYYqUW77wpDNQPcTCosJU12SdTUg9DDLdk1ct2itMNOzzSvI7BLZ2SDUe2obLhSBiyhnNNRYYEz1Jn9ukYxe0Y33BUK2kZY\/wR1rB4w1GYMaRR0DzBeD3AFOuBIyxO+M2B31YD+u46bLftwijZKDxsoq5jN0cPnNJdojrGlrAKW7bd1maK5YdZOoEKfn2x75RvJy30p3q0nTQoOWa0w9sI3Q6KU8Tttmr4wR4d7P2yFDuF5ON2W72cfLR4BxU7KseHwq1rpefcxPYpNSSMSXG8wWZibfJ+8+73a2Cvs17Dfee6XYAfn8SHQQjaufQsE1gUYru57fJDT3C0UnrWbhCESscisc2feEZCSfVyeCZ1XDso66vVmCKuOdglh00EtuJhE2GAw76SFNkaCM7ul9064Ge98MMmSg8txna0VDE+bMIuMaTN4ulhNqAfnUFC+M1z1BLg193CA18csdu96Ng7nOYuO8yO48ejmwtrTXzkl80PRyvzbKESfsxVdPjBzyUH0hgqh7MYNbYiWXowEsPPUTzprB+tMZaeH8jtU25IVmwMeu8oHfVUpsjxw0yWGJIt1hg6qxbRzAvhNx9+XVwiDYoPm2DtJe9OOmHpQXr8gK7iDmPqHEtHYc7CVsQvVDnpjB0arIafzrrbLBigDL9QGT9Wdih2XwW\/MvLZKrgjLK5yfjxTtEtPbBRHonVKzz+20weCEn5z4mcz\/LZtfVsNv\/LjxgJea0qQSU4kVJpX6auMq5irYAE45Qe4l0x9RDMVushkWHiCNOKndhh3dFp\/yON6ik8BD1Q55e2YymH8XLFTeswui08Iw2JOWYlFq2z3yfvNiZ+3JWKDYGty8Xc8OLzPZCsdos79RcmXQbCQJ4Ff\/0pEbE9E\/MpOLuQ9tY5KkiE\/YL+4NICf0Vf6jocoyEvpOzBE82SXGdIJO6pfscJHc8VZDcQJyKVDDRaG4KRPoyf85sQvQPxKv2Il4AGb9jxfIVJaD0QvLHSuBr9ABKKqtBE8YlQlSfH9FsUC+NniJIhSoO0Iv1LyA35rBU7nxU\/hK1bEdzwUDzVibVEa8n5z4WfoWzANjt\/xUIJfyPFzlPyFEn523Au7KlHHz052lyt8XVpQzinHTy2T83wBm0oHOZzrm9rUvoAtij5XNHuiSPjNg58DRwIAfl0F\/Gz+VK7KX3A34FwlfqHqF4yxgORQpXrxsY9hK+AXqGYyim5W+RY0lUnhvoBFuYeuBrSjil+Ysjh5vznxwxBI3SjFD0\/sUcIvdPpq+IndA53+1eLnqGRyDvwCpS84U8evz0+mshXwYwfQGCr9yVAdv0ClwRP7vgJ1s5P3uwx+3pYdwL6QBL+iWhOqPpC+2rAqEN8ze2WC9dBRc9F4qppK626oOWfAL5wDv0CFfJw4VnGToBTyw9oU8VMz5DxPOyDvdzn8DAjosvXyb7dlO\/EM1TpmqLhJlphhXyF+Id+np4JfXxzVV5pkXxE\/5WbECfoqC3T81iqcstI4il9uq9bgYYph5zJPm\/CbCz\/HwQh3BfyCkDezak2sSrtp968aP7E9MFTwLIpthKq0WsppoSGDuUaKKv19Rz1FW\/HbvC\/T2JL3mw8\/NNF2VwE\/tnPzSlnpTzvf+SoGgAr4hf05aqwSfuoZDPv9UB2WQNVNKnd+beVlVGXFkLzfZ+DnBAr49b+IBFeOn+Eor1OE14KfaghrPFJUK0z\/ilMsW+2fih95v0vgh\/SV4feFJAz71yXXhJ9yCGs\/\/mIFFTvaV97fUH40afQJP3X8njgcv+1rxO\/a6OuHVzjpOi9+4TwjxattxoIrN3mYmXmR8HOefJP47R2Vf9a4fvxuicyL3zU2T1+2wcvjZ9xm+o72ZuB3+LL8w46dw49Iuln4LZjkvZ\/t3Gb8Xh7OwO9Mofd58MQh70f4fUnv5zw5uNV9z7MZ+Cm5P1j+JfwIvy\/n\/WBx51txfhJ+k5cq\/D2xHcKP8Psy3s+xn9xu+l5OZuM3Odx9WTr\/cuAYT\/5D8rnSapENcvLEcG5xz\/Po5e7hpAi\/ydnh3i7J15BWi2zwjcne4dmkGD+SryWtFtngWxfCj\/AjIfwIPxLCj4TwIyH8CD8Swo+E8CMh\/Ag\/kluI3\/8HgyITxYdFCwgAAAAASUVORK5CYII=\" width=\"894\" height=\"515\"\n               alt=\"Seg Edit: a music bed and a voice track laid out on a timeline, with a volume envelope drawn across the music that dips under the speech.\">\n        <\/div>\n        <p class=\"ais-se-cap\">Seg Edit on an advert &mdash; the music ducked itself under the read, and every point on that yellow line is draggable.<\/p>\n        <div class=\"ais-se-grid\">\n          <div class=\"ais-se-card\">\n            <h4>&#128260; Slide the music<\/h4>\n            <p>Drag the bed off the front of your voice, or tuck it further under. Blocks snap\n               to each other&rsquo;s edges, so nothing plays over itself by accident.<\/p>\n          <\/div>\n          <div class=\"ais-se-card\">\n            <h4>&#9986; Trim, fade, automate<\/h4>\n            <p>Drag an edge to cut a long ending short. The yellow line is a real volume\n               envelope &mdash; double-tap to add a point, drag it where you want it. Same\n               idea as a multitrack editor, and it changes as it plays.<\/p>\n          <\/div>\n          <div class=\"ais-se-card\">\n            <h4>&#128176; It never costs a credit<\/h4>\n            <p>The mixing happens in your browser, not on our servers &mdash; so changing your\n               mind is free, every time, for as long as you like.<\/p>\n          <\/div>\n        <\/div>\n        <p class=\"ais-se-find\">&#127911; <b>Where to find it:<\/b> make an advert, sweeper, DJ drop\n          or podcast intro as usual, then press <b>Open Seg Edit<\/b> under the player. Works with a\n          mouse or a thumb &mdash; it&rsquo;s built for a phone too. Press <b>Reset<\/b> and you&rsquo;re\n          back to the original mix, so there is nothing to lose by having a go.<\/p>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-why\">\n      <div class=\"ais-why-card\">\n        <span class=\"ais-why-ico\">&#127908;<\/span>\n        <h4>Hear the voice before you pay<\/h4>\n        <p>Preview any of 1,279 voices for free, as many times as you like, before a single credit is spent.<\/p>\n        <div class=\"ais-vs\">Most AI jingle tools make you buy first.<\/div>\n      <\/div>\n      <div class=\"ais-why-card\">\n        <span class=\"ais-why-ico\">&#127925;<\/span>\n        <h4>Sung jingles while you wait<\/h4>\n        <p>A real sung hook with your station name, in 14 styles. Make one, two or four and pick your favourite.<\/p>\n        <div class=\"ais-vs\">Elsewhere: a form, $29, and a day&rsquo;s wait.<\/div>\n      <\/div>\n      <div class=\"ais-why-card\">\n        <span class=\"ais-why-ico\">&#128190;<\/span>\n        <h4>WAV, plus the dry voice<\/h4>\n        <p>Download broadcast WAV and a clean voice-only stem, so your producer can rework it later.<\/p>\n        <div class=\"ais-vs\">Not MP3-only, and not deleted in six weeks.<\/div>\n      <\/div>\n      <div class=\"ais-why-card\">\n        <span class=\"ais-why-ico\">&#127911;<\/span>\n        <h4>A human backstop<\/h4>\n        <p>If the AI doesn&rsquo;t nail it, Paul will produce it properly in our studio for &pound;15.<\/p>\n        <div class=\"ais-vs\">Nobody else offers this at all.<\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-trust\">\n      <span>&#127970; <b>Radio imaging since 2014<\/b><\/span>\n      <span>&#128225; <b>600+ stations on air with our audio<\/b><\/span>\n      <span>&#9989; <b>Full commercial licence, royalty-free<\/b><\/span>\n      <span>&#128274; <b>Your work kept for 90 days<\/b><\/span>\n    <\/div>\n\n    <div class=\"ais-honest\">\n      <div class=\"ico\">&#129302;<\/div>\n      <div>\n        <b>Straight with you about the AI.<\/b> Voices and music here are computer-generated, so results vary and the odd quirk comes with the territory &mdash; that&rsquo;s why previews are free, retakes are free, and Seg Edit lets you re-work a piece as many times as you like without spending anything. Because each piece is produced instantly and credits are spent the moment it&rsquo;s generated, <b>AI Studio creations are non-refundable<\/b>. If a generation genuinely fails, your credits come straight back automatically.\n        <div class=\"row2\">Want a <b>real human voice<\/b>, or <b>professional singers and producers<\/b>? That&rsquo;s a different service &mdash; browse our <a href=\"\/product\/commercial-production\/\">produced adverts<\/a> and <a href=\"\/product\/sung-audio\/\">human-sung jingles<\/a>.<\/div>\n      <\/div>\n    <\/div>\n\n  <\/div>\n\t\t<style>\r\n\t\t#rj24ais-cta{\r\n\t\t\tmargin: 0 0 22px;\r\n\t\t\tpadding: 22px 26px;\r\n\t\t\tborder-radius: 18px;\r\n\t\t\tbackground: linear-gradient(135deg, #FDF2F8 0%, #FCE7F3 100%);\r\n\t\t\tborder: 1px solid #FBCFE8;\r\n\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\r\n\t\t\tcolor: #1E293B;\r\n\t\t\tbox-shadow: 0 4px 20px rgba(236, 72, 153, 0.08);\r\n\t\t\tdisplay: flex;\r\n\t\t\talign-items: center;\r\n\t\t\tgap: 22px;\r\n\t\t\tflex-wrap: wrap;\r\n\t\t}\r\n\t\t#rj24ais-cta.rj24ais-cta-freefirst{\r\n\t\t\tbackground: linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 100%);\r\n\t\t\tborder-color: #6EE7B7;\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-body { flex: 1; min-width: 240px; }\r\n\t\t#rj24ais-cta .rj24ais-cta-eyebrow {\r\n\t\t\tdisplay: inline-block;\r\n\t\t\tfont-size: 11px;\r\n\t\t\tfont-weight: 800;\r\n\t\t\tletter-spacing: 0.12em;\r\n\t\t\ttext-transform: uppercase;\r\n\t\t\tcolor: #DB2777;\r\n\t\t\tbackground: #fff;\r\n\t\t\tborder: 1px solid #FBCFE8;\r\n\t\t\tpadding: 4px 10px;\r\n\t\t\tborder-radius: 999px;\r\n\t\t\tmargin-bottom: 8px;\r\n\t\t}\r\n\t\t#rj24ais-cta.rj24ais-cta-freefirst .rj24ais-cta-eyebrow{\r\n\t\t\tcolor: #059669;\r\n\t\t\tborder-color: #6EE7B7;\r\n\t\t}\r\n\t\t#rj24ais-cta h3 {\r\n\t\t\tfont-family: 'Anton', sans-serif;\r\n\t\t\ttext-transform: uppercase;\r\n\t\t\tfont-size: 26px;\r\n\t\t\tline-height: 1.05;\r\n\t\t\tmargin: 0 0 6px;\r\n\t\t\tcolor: #1E293B;\r\n\t\t\tletter-spacing: -0.3px;\r\n\t\t}\r\n\t\t#rj24ais-cta p { margin: 0; font-size: 14px; line-height: 1.5; color: #475569; }\r\n\t\t#rj24ais-cta .rj24ais-cta-actions {\r\n\t\t\tdisplay: flex;\r\n\t\t\tgap: 10px;\r\n\t\t\tflex-wrap: wrap;\r\n\t\t\talign-items: center;\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-btn {\r\n\t\t\tdisplay: inline-flex;\r\n\t\t\talign-items: center;\r\n\t\t\tgap: 6px;\r\n\t\t\tpadding: 13px 22px;\r\n\t\t\tborder-radius: 999px;\r\n\t\t\tfont-weight: 800;\r\n\t\t\tfont-size: 14px;\r\n\t\t\ttext-decoration: none;\r\n\t\t\ttransition: background 0.15s, transform 0.1s, box-shadow 0.15s;\r\n\t\t\tborder: 2px solid transparent;\r\n\t\t\twhite-space: nowrap;\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-primary {\r\n\t\t\tbackground: #EC4899;\r\n\t\t\tcolor: #fff;\r\n\t\t\tbox-shadow: 0 6px 18px rgba(236, 72, 153, 0.35);\r\n\t\t}\r\n\t\t#rj24ais-cta.rj24ais-cta-freefirst .rj24ais-cta-primary{\r\n\t\t\tbackground: #059669;\r\n\t\t\tbox-shadow: 0 6px 18px rgba(5, 150, 105, 0.35);\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-primary:hover {\r\n\t\t\tbackground: #DB2777;\r\n\t\t\tcolor: #fff;\r\n\t\t\ttransform: translateY(-1px);\r\n\t\t}\r\n\t\t#rj24ais-cta.rj24ais-cta-freefirst .rj24ais-cta-primary:hover{\r\n\t\t\tbackground: #047857;\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-secondary {\r\n\t\t\tbackground: #fff;\r\n\t\t\tcolor: #DB2777;\r\n\t\t\tborder-color: #FBCFE8;\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-secondary:hover {\r\n\t\t\tbackground: #FFF1F8;\r\n\t\t\tborder-color: #F9A8D4;\r\n\t\t\tcolor: #9D174D;\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-tertiary {\r\n\t\t\tbackground: transparent;\r\n\t\t\tcolor: #64748B;\r\n\t\t\tborder-color: #CBD5E1;\r\n\t\t\tfont-weight: 700;\r\n\t\t}\r\n\t\t#rj24ais-cta .rj24ais-cta-tertiary:hover {\r\n\t\t\tbackground: #F1F5F9;\r\n\t\t\tborder-color: #94A3B8;\r\n\t\t\tcolor: #1E293B;\r\n\t\t}\r\n\t\t@media (max-width: 600px) {\r\n\t\t\t#rj24ais-cta { padding: 18px; gap: 14px; }\r\n\t\t\t#rj24ais-cta h3 { font-size: 22px; }\r\n\t\t\t#rj24ais-cta .rj24ais-cta-actions { width: 100%; }\r\n\t\t\t#rj24ais-cta .rj24ais-cta-btn { flex: 1; justify-content: center; }\r\n\t\t}\r\n\t\t<\/style>\r\n\t\t<div id=\"rj24ais-cta\" class=\"\">\r\n\t\t\t\t\t<div class=\"rj24ais-cta-body\">\r\n\t\t\t\t<span class=\"rj24ais-cta-eyebrow\">\u26a1 Get started<\/span>\r\n\t\t\t\t<h3>Register \u2014 your first creation is free.<\/h3>\r\n\t\t\t\t<p>Make your first sweeper or DJ drop on us - your station name, a real voice, yours to keep. An email address and a password, nothing else, and no card.<\/p>\r\n\t\t\t<\/div>\r\n\t\t\t<div class=\"rj24ais-cta-actions\">\r\n\t\t\t\t<a href=\"https:\/\/radiojingles24.com\/ai-plans\/\" class=\"rj24ais-cta-btn rj24ais-cta-primary\">Register free &rarr;<\/a>\r\n\t\t\t\t\t\t\t\t\t<a href=\"https:\/\/radiojingles24.com\/it\/checkout\/?add-to-cart=116839\" class=\"rj24ais-cta-btn rj24ais-cta-secondary\">Top up 100 credits<\/a>\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t<a href=\"https:\/\/radiojingles24.com\/it\/checkout\/?add-to-cart=117267\" class=\"rj24ais-cta-btn rj24ais-cta-tertiary\">Try 40 credits \u2014 \u00a319<\/a>\r\n\t\t\t\t\t\t\t<\/div>\r\n\t\t\t\t<\/div>\r\n\t\t\n  <!-- TOOL TABS -->\n  <div class=\"ais-tabs\" id=\"ais-tools\">\n    <div class=\"ais-tab active\" data-tool=\"advert\">\ud83c\udf99\ufe0f Advert<small>A 30&ndash;60 second ad<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"sweeper\">\ud83d\udce3 Sweeper<small>Short station ID<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"djdrop\">\ud83c\udfa7 DJ Drop<small>Presenter shout<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"podcast\">\ud83c\udfac Podcast Intro\/Outro<small>Show open or close<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"sung\">\ud83c\udfb5 Sung Jingle<small>Live now<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"sty\">\ud83c\udfa4 Say It Yourself<small>Your delivery, our voice<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"creations\">\ud83d\uddc2\ufe0f My Creations<small>Everything you&rsquo;ve made<\/small><\/div>\n  <\/div>\n\n  <!-- ADVERT PANEL -->\n  <div class=\"ais-panel\" id=\"ais-advert-panel\">\n\n    <!-- RJ24-EDIT (Jul 2026): Type \/ Record mode toggle -->\n    <div class=\"ais-modebar\" id=\"ais-modebar\" style=\"display:none;\">\n      <span class=\"ais-delivery-lab\">How do you want the voice?<\/span>\n      <div class=\"ais-pills ais-modetoggle\" id=\"ais-modetoggle\">\n        <button type=\"button\" data-mode=\"type\" class=\"active\">\u2328\ufe0f Type it<\/button>\n        <button type=\"button\" data-mode=\"record\">\ud83c\udfa4 Say it yourself<\/button>\n      <\/div>\n      <p class=\"ais-mode-hint\" id=\"ais-mode-hint\">Type a script for an AI voice, or record the line yourself and convert it into an AI voice \u2014 your own timing and delivery carry through.<\/p>\n    <\/div>\n\n    <div class=\"ais-step\" id=\"ais-type-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Type your advert script<\/div>\n      <div class=\"ais-step-sub\">Around <b>30\u201360 words<\/b> works best for a punchy advert. The music plays under your voice and fades as you finish.<\/div>\n      <div class=\"ais-step-sub\" style=\"background:#FDF2F8;border-left:3px solid #EC4899;padding:10px 14px;border-radius:8px;margin-top:8px;font-size:13px;\">\n        \ud83d\udca1 <b>Pro tip:<\/b> Put your final tagline on its own line with a blank line before it &mdash; like this:<br>\n        <code style=\"display:block;background:#fff;padding:8px 10px;margin:8px 0 0;border-radius:6px;font-size:12px;color:#475569;line-height:1.5;\">Save up to 50% this weekend at Dave's Carpets &mdash; plus free fitting.<br><br>Dave's Carpets, Leeds.<\/code>\n        <span style=\"display:block;margin-top:6px;font-size:12px;\">The blank line helps the AI land the closing tagline cleanly instead of running it on or cutting it short.<\/span>\n      <\/div>\n      <textarea id=\"ais-script\" data-rj-script class=\"ais-textarea\" rows=\"4\" placeholder=\"e.g. This weekend only at Dave's Carpets \u2014 up to half price on all flooring, plus free fitting. Don't miss it. Dave's Carpets, Leeds.\"><\/textarea>\n      <div class=\"ais-count\" id=\"ais-count\">0 words &middot; \u2248 0 sec<\/div>\n      <div class=\"ais-target\">\n        <span class=\"ais-delivery-lab\">Target length<\/span>\n        <div class=\"ais-pills\" id=\"ais-target\">\n          <button type=\"button\" data-t=\"0\" class=\"active\">No target<\/button>\n          <button type=\"button\" data-t=\"15\">15 sec<\/button>\n          <button type=\"button\" data-t=\"30\">30 sec<\/button>\n          <button type=\"button\" data-t=\"60\">60 sec<\/button>\n        <\/div>\n        <p class=\"ais-hint\" id=\"ais-target-hint\" style=\"margin-top:6px;\"><\/p>\n      <\/div>\n      <div class=\"ais-delivery\">\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Delivery<\/span>\n          <div class=\"ais-pills\" id=\"ais-delivery\">\n            <button type=\"button\" data-d=\"lively\">Lively<\/button>\n            <button type=\"button\" data-d=\"balanced\" class=\"active\">Balanced<\/button>\n            <button type=\"button\" data-d=\"subdued\">Subdued<\/button>\n          <\/div>\n        <\/div>\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Pace<\/span>\n          <div class=\"ais-pills\" id=\"ais-pace\">\n            <button type=\"button\" data-p=\"slow\">Slower<\/button>\n            <button type=\"button\" data-p=\"normal\" class=\"active\">Normal<\/button>\n            <button type=\"button\" data-p=\"fast\">Quicker<\/button>\n          <\/div>\n        <\/div>\n      <\/div>\n      <div class=\"ais-pronounce\">\n        <label class=\"ais-pronounce-lab\" for=\"ais-pronounce\">Tricky words? <span>Tell us how to say them (optional)<\/span><\/label>\n        <input type=\"text\" id=\"ais-pronounce\" class=\"ais-input\" placeholder=\"e.g. Featherstone = Fetherstun;  Cholmondeley = Chumley\">\n        <p class=\"ais-hint\" style=\"margin-top:6px;\">Write <b>word = how it sounds<\/b>, separated by semicolons. We'll spell it out for the AI so it says it right.<\/p>\n      <\/div>\n    <\/div>\n\n    <!-- RJ24-EDIT (Jul 2026): record-your-own step (shown in Record mode) -->\n    <div class=\"ais-step ais-record-step\" id=\"ais-record-step\" style=\"display:none;\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Record your advert read<\/div>\n      <div class=\"ais-record-instructions\">\n        <b>Before you record:<\/b>\n        <ul>\n          <li>\ud83c\udfa7 Record somewhere quiet \u2014 any background noise gets picked up and carried into the converted voice.<\/li>\n          <li>\ud83c\udf99\ufe0f Get close to the mic and perform it: your pacing, emphasis and energy all carry through.<\/li>\n          <li>\u23f1\ufe0f Keep it under <span class=\"ais-cap\">1 minute 30 seconds<\/span> \u2014 recording stops automatically at the limit.<\/li>\n        <\/ul>\n      <\/div>\n      <div class=\"ais-record-box\">\n        <button type=\"button\" class=\"ais-record-btn\">&#9679;<\/button>\n        <div class=\"ais-record-status\">Tap to start recording<\/div>\n        <div class=\"ais-record-time\" style=\"display:none;\">0:00 \/ 1:30<\/div>\n        <div class=\"ais-record-preview\" style=\"display:none;\">\n          <audio class=\"ais-record-audio\" controls style=\"width:100%;\"><\/audio>\n          <div style=\"margin-top:8px;\"><button type=\"button\" class=\"ais-retry-btn ais-record-again\">Record again<\/button><\/div>\n        <\/div>\n        <div class=\"ais-record-upload\">\n          or <label style=\"text-decoration:underline;cursor:pointer;\">upload an audio file<input type=\"file\" class=\"ais-record-file\" accept=\"audio\/*\" style=\"display:none;\"><\/label> instead of recording live\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Music<\/div>\n      <div class=\"ais-step-sub\">One choice. Pick our music and it costs nothing, however often you change it.<\/div>\n      <div class=\"ais-musicmode\" id=\"ais-musicmode\">\n        <button type=\"button\" data-mm=\"library\" class=\"active\"><b>Use our music<\/b><small>1,700+ beds &middot; free<\/small><\/button>\n        <button type=\"button\" data-mm=\"none\"><b>No music<\/b><small>Voice only<\/small><\/button>\n        <button type=\"button\" data-mm=\"composed\"><b>Composed for you<\/b><small>Written for this advert &middot; costs extra<\/small><\/button>\n      <\/div>\n\n      <div id=\"ais-mm-library\" class=\"ais-mm-panel\">\n        <div class=\"ais-sfx-picker\" id=\"ais-sfx-picker\"><\/div>\n        <p class=\"ais-sfx-picker-hint\" id=\"ais-sfx-hint\"><\/p>\n      <\/div>\n\n      <div id=\"ais-mm-composed\" class=\"ais-mm-panel\" style=\"display:none;\">\n        <div class=\"ais-step-sub\" style=\"padding-left:0;\">A fresh backing track written for this piece alone. It takes longer and uses credits &mdash; a library bed is free and usually just as good.<\/div>\n        <div class=\"ais-genres\" id=\"ais-genres\"><\/div>\n        <div class=\"ais-prompt-wrap\" id=\"ais-prompt-wrap\">\n          <input type=\"text\" id=\"ais-prompt\" class=\"ais-input\" placeholder=\"Describe the music you want \u2014 e.g. 'gentle acoustic guitar, warm and friendly'\">\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">3<\/span> Choose a voice<\/div>\n      <div class=\"ais-step-sub\">An AI voice reads your script. Search or filter by country and type, then tap \u25b6 to preview.<\/div>\n      <div class=\"ais-record-voicenote\" id=\"ais-record-voicenote\">\ud83c\udfa4 Record mode: your own delivery and timing carry through \u2014 this is the voice your recording is converted into.<\/div>\n      <div class=\"ais-voice-controls\" id=\"ais-voice-controls\"><\/div>\n      <div class=\"ais-voices\" id=\"ais-voices\"><\/div>\n    <\/div>\n\n    <!-- STEP 4: options -->\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">4<\/span> Balance<\/div>\n      <!-- RJ24-EDIT (Aug 2026): the \"add music (+3 credits)\" checkbox contradicted\n           the free choice in step 2. It is now driven by that choice and hidden. -->\n      <label class=\"ais-toggle\" id=\"ais-music-wrap\" style=\"display:none;\">\n        <input type=\"checkbox\" id=\"ais-music\"> <span>Add AI music behind my voice<\/span>\n      <\/label>\n      <div class=\"ais-musiclevel\" id=\"ais-musiclevel-wrap\" style=\"display:none;\">\n        <span class=\"ais-delivery-lab\">How loud the bed sits under your voice<\/span>\n        <div class=\"ais-pills\" id=\"ais-musiclevel\">\n          <button type=\"button\" data-m=\"low\">Low<\/button>\n          <button type=\"button\" data-m=\"medium\" class=\"active\">Medium<\/button>\n          <button type=\"button\" data-m=\"high\">High<\/button>\n        <\/div>\n      <\/div>\n      <div class=\"ais-cost\" id=\"ais-cost\">Cost: <b>1 credit<\/b><\/div>\n    <\/div>\n\n    <!-- RESULT -->\n    <div class=\"ais-result\" id=\"ais-result\">\n      <div id=\"ais-result-loading\">\n        <div class=\"ais-spinner\"><\/div>\n        <div id=\"ais-result-msg\" style=\"font-weight:700;\">Creating your advert\u2026<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:4px;\">Recording your voiceover<span id=\"ais-result-music\"> and composing music<\/span><\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:8px;font-style:italic;\">This takes around 20&ndash;30 seconds &mdash; no need to press again. Your finished advert appears right here when it&rsquo;s done.<\/div>\n      <\/div>\n      <div id=\"ais-result-ready\" style=\"display:none;\">\n        <div style=\"font-weight:800;font-size:17px;color:#065F46;\">\u2713 Your advert is ready!<\/div>\n        <audio id=\"ais-audio\" controls><\/audio>\n        <div id=\"ais-length\" style=\"font-size:12px;font-weight:700;color:#475569;margin-top:8px;\"><\/div>\n        <div class=\"ais-downloads\" id=\"ais-downloads\">\n          <a id=\"ais-dl-main\" class=\"ais-dl-btn\" download=\"advert.wav\" href=\"#\">\u2b07 Download advert<\/a>\n          <a id=\"ais-dl-dry\" class=\"ais-dl-btn ais-dl-secondary\" download=\"advert-voice-only.wav\" href=\"#\" style=\"display:none;\">\u2b07 Download voice only (no music)<\/a>\n        <\/div>\n        <p class=\"ais-save-note\">\ud83d\udcbe Also auto-saved as an MP3 to your <a href=\"#\" class=\"ais-gocreations\">My Creations<\/a> tab (top of this tool) for 90 days &mdash; that&rsquo;s where your downloads live, <b>not<\/b> My Account. For WAV quality, grab it above now.<\/p>\n        <div style=\"font-size:12px;color:#475569;margin-top:10px;\">Yours to keep, royalty-free. Not quite right? Tweak and create again.<\/div>\n\n        <!-- \u00a315 professional production upsell -->\n        <div class=\"ais-produce\" id=\"ais-produce\">\n          <div class=\"ais-produce-body\">\n            <b>Want it polished by the pros?<\/b>\n            <span><b>The AI voice stays exactly as it is<\/b> &mdash; Paul takes your generated audio into his studio and produces around it: professional music, SFX and broadcast-quality mixing. No re-recording, no swap to a human voice. <b>\u00a3<span id=\"ais-produce-fee\">15<\/span> extra.<\/b><\/span>\n          <\/div>\n          <button type=\"button\" class=\"ais-produce-btn\" id=\"ais-produce-btn\">Get RJ24 to produce it &rarr;<\/button>\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <!-- HOW IT WORKS -->\n    <div class=\"ais-how\">\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">1<\/div><h4>You write it<\/h4><p>Type your advert script and pick a music vibe and voice.<\/p><\/div>\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">2<\/div><h4>AI builds it<\/h4><p>We generate the music and voiceover, then mix them with a clean fade-out.<\/p><\/div>\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">3<\/div><h4>You keep it<\/h4><p>Preview, then download your royalty-free advert to use anywhere.<\/p><\/div>\n    <\/div>\n\n  <\/div>\n\n  <!-- SWEEPER PANEL -->\n  <div class=\"ais-panel\" id=\"ais-sweeper-panel\" style=\"display:none;\">\n\n    <!-- RJ24-EDIT (Jul 2026): Type \/ Record mode toggle -->\n    <div class=\"ais-modebar\" id=\"sw-modebar\" style=\"display:none;\">\n      <span class=\"ais-delivery-lab\">How do you want the voice?<\/span>\n      <div class=\"ais-pills ais-modetoggle\" id=\"sw-modetoggle\">\n        <button type=\"button\" data-mode=\"type\" class=\"active\">\u2328\ufe0f Type it<\/button>\n        <button type=\"button\" data-mode=\"record\">\ud83c\udfa4 Say it yourself<\/button>\n      <\/div>\n      <p class=\"ais-mode-hint\">Type a line for an AI voice, or record it yourself and convert it into an AI voice \u2014 your own energy and timing carry through.<\/p>\n    <\/div>\n\n    <div class=\"ais-step\" id=\"sw-type-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Type your sweeper line<\/div>\n      <div class=\"ais-step-sub\">Short and punchy works best \u2014 <b>a few words to one line<\/b>. e.g. \"You're listening to the UK's number one for hits!\"<br><span style=\"font-size:12px;color:#94A3B8;\">Tip: a full stop gives a clear pause; a comma or dash gives a shorter breath.<\/span><\/div>\n      <textarea id=\"sw-script\" data-rj-script class=\"ais-textarea\" rows=\"2\" placeholder=\"e.g. Rosie FM \u2014 the biggest hits, all day long!\"><\/textarea>\n      <div class=\"ais-count\" id=\"sw-count\">0 words<\/div>\n      <div class=\"ais-delivery\" style=\"margin-top:14px;\">\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Delivery<\/span>\n          <div class=\"ais-pills\" id=\"sw-delivery\">\n            <button type=\"button\" data-d=\"lively\" class=\"active\">Lively<\/button>\n            <button type=\"button\" data-d=\"balanced\">Balanced<\/button>\n            <button type=\"button\" data-d=\"subdued\">Subdued<\/button>\n          <\/div>\n        <\/div>\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Pace<\/span>\n          <div class=\"ais-pills\" id=\"sw-pace\">\n            <button type=\"button\" data-p=\"slow\">Slower<\/button>\n            <button type=\"button\" data-p=\"normal\" class=\"active\">Normal<\/button>\n            <button type=\"button\" data-p=\"fast\">Quicker<\/button>\n          <\/div>\n        <\/div>\n      <\/div>\n      <div class=\"ais-pronounce\">\n        <label class=\"ais-pronounce-lab\" for=\"sw-pronounce\">Tricky words? <span>Tell us how to say them (optional)<\/span><\/label>\n        <input type=\"text\" id=\"sw-pronounce\" class=\"ais-input\" placeholder=\"e.g. Featherstone = Fetherstun\">\n      <\/div>\n    <\/div>\n\n    <!-- RJ24-EDIT (Jul 2026): record-your-own step (shown in Record mode) -->\n    <div class=\"ais-step ais-record-step\" id=\"sw-record-step\" style=\"display:none;\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Record your sweeper line<\/div>\n      <div class=\"ais-record-instructions\">\n        <b>Before you record:<\/b>\n        <ul>\n          <li>\ud83c\udfa7 Record somewhere quiet \u2014 any background noise gets picked up and carried into the converted voice.<\/li>\n          <li>\ud83c\udf99\ufe0f Get close to the mic and give it real attitude \u2014 your delivery carries straight through.<\/li>\n          <li>\u23f1\ufe0f Keep it under <span class=\"ais-cap\">15 seconds<\/span> \u2014 recording stops automatically at the limit.<\/li>\n        <\/ul>\n      <\/div>\n      <div class=\"ais-record-box\">\n        <button type=\"button\" class=\"ais-record-btn\">&#9679;<\/button>\n        <div class=\"ais-record-status\">Tap to start recording<\/div>\n        <div class=\"ais-record-time\" style=\"display:none;\">0:00 \/ 0:15<\/div>\n        <div class=\"ais-record-preview\" style=\"display:none;\">\n          <audio class=\"ais-record-audio\" controls style=\"width:100%;\"><\/audio>\n          <div style=\"margin-top:8px;\"><button type=\"button\" class=\"ais-retry-btn ais-record-again\">Record again<\/button><\/div>\n        <\/div>\n        <div class=\"ais-record-upload\">\n          or <label style=\"text-decoration:underline;cursor:pointer;\">upload an audio file<input type=\"file\" class=\"ais-record-file\" accept=\"audio\/*\" style=\"display:none;\"><\/label> instead of recording live\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Music &amp; effects <span style=\"font-weight:600;font-size:12px;color:#059669;\">(free)<\/span><\/div>\n      <div class=\"ais-step-sub\">Build it from 1,700+ pieces: something to <b>open<\/b> on, something in the <b>middle<\/b> to land on a pause, something to <b>close<\/b> with. Tap \u25b6 to hear anything. It all costs nothing, and you can change it as often as you like \u2014 even after you\u2019ve made it.<\/div>\n      <div class=\"ais-sfx-picker\" id=\"sw-sfx-picker\"><\/div>\n      <p class=\"ais-sfx-picker-hint\" id=\"sw-sfx-hint\">No pieces selected \u2014 your sweeper will be dry (voice only, or voice + music sting if you add one below).<\/p>\n    <\/div>\n\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">3<\/span> Choose a voice<\/div>\n      <div class=\"ais-step-sub\">Search or filter by country and type, then tap \u25b6 to preview.<\/div>\n      <div class=\"ais-record-voicenote\" id=\"sw-record-voicenote\">\ud83c\udfa4 Record mode: your own delivery and timing carry through \u2014 this is the voice your recording is converted into.<\/div>\n      <div class=\"ais-voice-controls\" id=\"sw-voice-controls\"><\/div>\n      <div class=\"ais-voices\" id=\"sw-voices\"><\/div>\n    <\/div>\n\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">4<\/span> Balance<\/div>\n      <!-- RJ24-EDIT (Aug 2026): the paid AI-composed sting is retired here. Beds\n           come from the library above and cost nothing, which is cheaper and\n           sounds better. The checkbox stays in the DOM, unchecked and hidden, so\n           none of the handlers that reference it have to change. -->\n      <p class=\"ais-step-sub\" style=\"padding-left:0;\">Pick an <b>Opener<\/b>, a <b>Middle<\/b> and a <b>Closer<\/b> in the sound library above &mdash; three production elements with your voice through them. All free, and you can change them as often as you like.<\/p>\n      <label class=\"ais-toggle\" id=\"sw-sting-wrap\" style=\"display:none;\">\n        <input type=\"checkbox\" id=\"sw-sting\"> <span>Add a short musical sting under the sweeper for extra punch.<\/span>\n      <\/label>\n      <!-- RJ24-EDIT (Nov 2026): genre picker for the sting \u2014 replaces the hardcoded 'upbeat' prompt. -->\n      <div class=\"ais-sting-genre-wrap\" id=\"sw-sting-genre-wrap\" style=\"display:none; margin-top:14px;\">\n        <span class=\"ais-delivery-lab\" style=\"display:block;margin-bottom:8px;\">Sting music style<\/span>\n        <div class=\"ais-genres ais-genres-compact\" id=\"sw-sting-genre\"><\/div>\n        <div class=\"ais-prompt-wrap\" id=\"sw-sting-prompt-wrap\">\n          <input type=\"text\" id=\"sw-sting-prompt\" class=\"ais-input\" placeholder=\"Describe the music \u2014 e.g. 'tight, punchy, energetic mainstream pop'\">\n        <\/div>\n        <!-- v1.6.0: tempo control \u2014 nudges the auto-BPM by \u00b115. -->\n        <div class=\"ais-delivery-group\" style=\"margin-top:12px;\">\n          <span class=\"ais-delivery-lab\">Tempo<\/span>\n          <div class=\"ais-pills\" id=\"sw-sting-tempo\">\n            <button type=\"button\" data-tempo=\"slower\">Slower<\/button>\n            <button type=\"button\" data-tempo=\"normal\" class=\"active\">Normal<\/button>\n            <button type=\"button\" data-tempo=\"faster\">Faster<\/button>\n          <\/div>\n        <\/div>\n      <\/div>\n      <!-- no bed on a sweeper or a DJ drop, so nothing to set a bed level for -->\n      <div class=\"ais-musiclevel\" id=\"sw-musiclevel-wrap\" style=\"display:none;\">\n        <span class=\"ais-delivery-lab\">How loud the bed sits under your voice<\/span>\n        <div class=\"ais-pills\" id=\"sw-musiclevel\">\n          <button type=\"button\" data-m=\"low\">Low<\/button>\n          <button type=\"button\" data-m=\"medium\" class=\"active\">Medium<\/button>\n          <button type=\"button\" data-m=\"high\">High<\/button>\n        <\/div>\n      <\/div>\n      <div class=\"ais-cost\" id=\"sw-cost\">Cost: <b>1 credit<\/b><\/div>\n    <\/div>\n\n    <div class=\"ais-result\" id=\"sw-result\">\n      <div id=\"sw-result-loading\">\n        <div class=\"ais-spinner\"><\/div>\n        <div style=\"font-weight:700;\">Creating your sweeper\u2026<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:4px;\">Recording the voice and generating the effect<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:8px;font-style:italic;\">This takes around 20&ndash;30 seconds &mdash; no need to press again. It appears right here when it&rsquo;s done.<\/div>\n      <\/div>\n      <div id=\"sw-result-ready\" style=\"display:none;\">\n        <div style=\"font-weight:800;font-size:17px;color:#065F46;\">\u2713 Your sweeper is ready!<\/div>\n        <audio id=\"sw-audio\" controls><\/audio>\n        <div class=\"ais-downloads\">\n          <a id=\"sw-dl\" class=\"ais-dl-btn\" download=\"sweeper.wav\" href=\"#\">\u2b07 Download sweeper<\/a>\n        <\/div>\n        <p class=\"ais-save-note\">\ud83d\udcbe Also auto-saved as an MP3 to your <a href=\"#\" class=\"ais-gocreations\">My Creations<\/a> tab (top of this tool) for 90 days &mdash; that&rsquo;s where your downloads live, <b>not<\/b> My Account. For WAV quality, grab it above now.<\/p>\n        <!-- \u00a315 professional production upsell -->\n        <div class=\"ais-produce\" id=\"sw-produce\">\n          <div class=\"ais-produce-body\">\n            <b>Want it polished by the pros?<\/b>\n            <span><b>The AI voice stays exactly as it is<\/b> &mdash; Paul takes your generated audio into his studio and produces around it: professional music, SFX and broadcast-quality mixing. No re-recording, no swap to a human voice. <b>\u00a3<span id=\"sw-produce-fee\">15<\/span> extra.<\/b><\/span>\n          <\/div>\n          <button type=\"button\" class=\"ais-produce-btn\" id=\"sw-produce-btn\">Get RJ24 to produce it &rarr;<\/button>\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-how\">\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">1<\/div><h4>Write the line<\/h4><p>A punchy one-liner and a voice with attitude.<\/p><\/div>\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">2<\/div><h4>Build it<\/h4><p>Choose an opener, a middle and a closer from 1,700+ pieces. All free, all swappable.<\/p><\/div>\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">3<\/div><h4>Finish it<\/h4><p>Not sitting right? Open <b>Seg Edit<\/b> and move it yourself, free. Then download it. Royalty-free, ready for air.<\/p><\/div>\n    <\/div>\n\n  <\/div>\n\n  <!-- DJ DROP PANEL (same as sweeper) -->\n  <div class=\"ais-panel\" id=\"ais-djdrop-panel\" style=\"display:none;\">\n    <!-- RJ24-EDIT (Jul 2026): Type \/ Record mode toggle -->\n    <div class=\"ais-modebar\" id=\"dj-modebar\" style=\"display:none;\">\n      <span class=\"ais-delivery-lab\">How do you want the voice?<\/span>\n      <div class=\"ais-pills ais-modetoggle\" id=\"dj-modetoggle\">\n        <button type=\"button\" data-mode=\"type\" class=\"active\">\u2328\ufe0f Type it<\/button>\n        <button type=\"button\" data-mode=\"record\">\ud83c\udfa4 Say it yourself<\/button>\n      <\/div>\n      <p class=\"ais-mode-hint\">Type a line for an AI voice, or record it yourself and convert it into an AI voice \u2014 your own energy and timing carry through.<\/p>\n    <\/div>\n\n    <div class=\"ais-step\" id=\"dj-type-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Type your DJ drop line<\/div>\n      <div class=\"ais-step-sub\">Short and punchy \u2014 <b>a few words to one line<\/b>. e.g. \"DJ Mike \u2014 in the mix!\"<br><span style=\"font-size:12px;color:#94A3B8;\">Tip: a full stop gives a clear pause; a comma or dash gives a shorter breath.<\/span><\/div>\n      <textarea id=\"dj-script\" data-rj-script class=\"ais-textarea\" rows=\"2\" placeholder=\"e.g. You're locked in with DJ Mike \u2014 let's go!\"><\/textarea>\n      <div class=\"ais-count\" id=\"dj-count\">0 words<\/div>\n      <div class=\"ais-delivery\" style=\"margin-top:14px;\">\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Delivery<\/span>\n          <div class=\"ais-pills\" id=\"dj-delivery\">\n            <button type=\"button\" data-d=\"lively\" class=\"active\">Lively<\/button>\n            <button type=\"button\" data-d=\"balanced\">Balanced<\/button>\n            <button type=\"button\" data-d=\"subdued\">Subdued<\/button>\n          <\/div>\n        <\/div>\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Pace<\/span>\n          <div class=\"ais-pills\" id=\"dj-pace\">\n            <button type=\"button\" data-p=\"slow\">Slower<\/button>\n            <button type=\"button\" data-p=\"normal\" class=\"active\">Normal<\/button>\n            <button type=\"button\" data-p=\"fast\">Quicker<\/button>\n          <\/div>\n        <\/div>\n      <\/div>\n      <div class=\"ais-pronounce\">\n        <label class=\"ais-pronounce-lab\" for=\"dj-pronounce\">Tricky words? <span>Tell us how to say them (optional)<\/span><\/label>\n        <input type=\"text\" id=\"dj-pronounce\" class=\"ais-input\" placeholder=\"e.g. Featherstone = Fetherstun\">\n      <\/div>\n    <\/div>\n    <!-- RJ24-EDIT (Jul 2026): record-your-own step (shown in Record mode) -->\n    <div class=\"ais-step ais-record-step\" id=\"dj-record-step\" style=\"display:none;\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Record your DJ drop line<\/div>\n      <div class=\"ais-record-instructions\">\n        <b>Before you record:<\/b>\n        <ul>\n          <li>\ud83c\udfa7 Record somewhere quiet \u2014 any background noise gets picked up and carried into the converted voice.<\/li>\n          <li>\ud83c\udf99\ufe0f Get close to the mic and give it real attitude \u2014 your delivery carries straight through.<\/li>\n          <li>\u23f1\ufe0f Keep it under <span class=\"ais-cap\">15 seconds<\/span> \u2014 recording stops automatically at the limit.<\/li>\n        <\/ul>\n      <\/div>\n      <div class=\"ais-record-box\">\n        <button type=\"button\" class=\"ais-record-btn\">&#9679;<\/button>\n        <div class=\"ais-record-status\">Tap to start recording<\/div>\n        <div class=\"ais-record-time\" style=\"display:none;\">0:00 \/ 0:15<\/div>\n        <div class=\"ais-record-preview\" style=\"display:none;\">\n          <audio class=\"ais-record-audio\" controls style=\"width:100%;\"><\/audio>\n          <div style=\"margin-top:8px;\"><button type=\"button\" class=\"ais-retry-btn ais-record-again\">Record again<\/button><\/div>\n        <\/div>\n        <div class=\"ais-record-upload\">\n          or <label style=\"text-decoration:underline;cursor:pointer;\">upload an audio file<input type=\"file\" class=\"ais-record-file\" accept=\"audio\/*\" style=\"display:none;\"><\/label> instead of recording live\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Music &amp; effects <span style=\"font-weight:600;font-size:12px;color:#059669;\">(free)<\/span><\/div>\n      <div class=\"ais-step-sub\">Build it from 1,700+ pieces: something to <b>open<\/b> on, something in the <b>middle<\/b> to land on a pause, something to <b>close<\/b> with. Tap \u25b6 to hear anything. It all costs nothing, and you can change it as often as you like \u2014 even after you\u2019ve made it.<\/div>\n      <div class=\"ais-sfx-picker\" id=\"dj-sfx-picker\"><\/div>\n      <p class=\"ais-sfx-picker-hint\" id=\"dj-sfx-hint\">No pieces selected \u2014 your DJ drop will be dry (voice only, or voice + music sting if you add one below).<\/p>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">3<\/span> Choose a voice<\/div>\n      <div class=\"ais-step-sub\">Search or filter by country and type, then tap \u25b6 to preview.<\/div>\n      <div class=\"ais-record-voicenote\" id=\"dj-record-voicenote\">\ud83c\udfa4 Record mode: your own delivery and timing carry through \u2014 this is the voice your recording is converted into.<\/div>\n      <div class=\"ais-voice-controls\" id=\"dj-voice-controls\"><\/div>\n      <div class=\"ais-voices\" id=\"dj-voices\"><\/div>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">4<\/span> Balance<\/div>\n      <!-- RJ24-EDIT (Aug 2026): the paid AI-composed sting is retired here. Beds\n           come from the library above and cost nothing, which is cheaper and\n           sounds better. The checkbox stays in the DOM, unchecked and hidden, so\n           none of the handlers that reference it have to change. -->\n      <p class=\"ais-step-sub\" style=\"padding-left:0;\">Pick an <b>Opener<\/b>, a <b>Middle<\/b> and a <b>Closer<\/b> in the sound library above &mdash; three production elements with your voice through them. All free, and you can change them as often as you like.<\/p>\n      <label class=\"ais-toggle\" id=\"dj-sting-wrap\" style=\"display:none;\">\n        <input type=\"checkbox\" id=\"dj-sting\"> <span>Add a short musical sting under the drop for extra punch.<\/span>\n      <\/label>\n      <!-- RJ24-EDIT (Nov 2026): genre picker for the sting \u2014 replaces the hardcoded 'upbeat' prompt. -->\n      <div class=\"ais-sting-genre-wrap\" id=\"dj-sting-genre-wrap\" style=\"display:none; margin-top:14px;\">\n        <span class=\"ais-delivery-lab\" style=\"display:block;margin-bottom:8px;\">Sting music style<\/span>\n        <div class=\"ais-genres ais-genres-compact\" id=\"dj-sting-genre\"><\/div>\n        <div class=\"ais-prompt-wrap\" id=\"dj-sting-prompt-wrap\">\n          <input type=\"text\" id=\"dj-sting-prompt\" class=\"ais-input\" placeholder=\"Describe the music \u2014 e.g. 'tight, punchy, energetic mainstream pop'\">\n        <\/div>\n        <!-- v1.6.0: tempo control \u2014 nudges the auto-BPM by \u00b115. -->\n        <div class=\"ais-delivery-group\" style=\"margin-top:12px;\">\n          <span class=\"ais-delivery-lab\">Tempo<\/span>\n          <div class=\"ais-pills\" id=\"dj-sting-tempo\">\n            <button type=\"button\" data-tempo=\"slower\">Slower<\/button>\n            <button type=\"button\" data-tempo=\"normal\" class=\"active\">Normal<\/button>\n            <button type=\"button\" data-tempo=\"faster\">Faster<\/button>\n          <\/div>\n        <\/div>\n      <\/div>\n      <!-- no bed on a sweeper or a DJ drop, so nothing to set a bed level for -->\n      <div class=\"ais-musiclevel\" id=\"dj-musiclevel-wrap\" style=\"display:none;\">\n        <span class=\"ais-delivery-lab\">How loud the bed sits under your voice<\/span>\n        <div class=\"ais-pills\" id=\"dj-musiclevel\">\n          <button type=\"button\" data-m=\"low\">Low<\/button>\n          <button type=\"button\" data-m=\"medium\" class=\"active\">Medium<\/button>\n          <button type=\"button\" data-m=\"high\">High<\/button>\n        <\/div>\n      <\/div>\n      <div class=\"ais-cost\" id=\"dj-cost\">Cost: <b>1 credit<\/b><\/div>\n    <\/div>\n    <div class=\"ais-result\" id=\"dj-result\">\n      <div id=\"dj-result-loading\">\n        <div class=\"ais-spinner\"><\/div>\n        <div style=\"font-weight:700;\">Creating your DJ drop\u2026<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:4px;\">Recording the voice and generating the effect<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:8px;font-style:italic;\">This takes around 20&ndash;30 seconds &mdash; no need to press again. It appears right here when it&rsquo;s done.<\/div>\n      <\/div>\n      <div id=\"dj-result-ready\" style=\"display:none;\">\n        <div style=\"font-weight:800;font-size:17px;color:#065F46;\">\u2713 Your DJ drop is ready!<\/div>\n        <audio id=\"dj-audio\" controls><\/audio>\n        <div class=\"ais-downloads\"><a id=\"dj-dl\" class=\"ais-dl-btn\" download=\"dj-drop.wav\" href=\"#\">\u2b07 Download DJ drop<\/a><\/div>\n        <p class=\"ais-save-note\">\ud83d\udcbe Also auto-saved as an MP3 to your <a href=\"#\" class=\"ais-gocreations\">My Creations<\/a> tab (top of this tool) for 90 days &mdash; that&rsquo;s where your downloads live, <b>not<\/b> My Account. For WAV quality, grab it above now.<\/p>\n        <!-- \u00a315 professional production upsell -->\n        <div class=\"ais-produce\" id=\"dj-produce\">\n          <div class=\"ais-produce-body\">\n            <b>Want it polished by the pros?<\/b>\n            <span><b>The AI voice stays exactly as it is<\/b> &mdash; Paul takes your generated audio into his studio and produces around it: professional music, SFX and broadcast-quality mixing. No re-recording, no swap to a human voice. <b>\u00a3<span id=\"dj-produce-fee\">15<\/span> extra.<\/b><\/span>\n          <\/div>\n          <button type=\"button\" class=\"ais-produce-btn\" id=\"dj-produce-btn\">Get RJ24 to produce it &rarr;<\/button>\n        <\/div>\n      <\/div>\n    <\/div>\n  <\/div>\n\n  <!-- PODCAST INTRO\/OUTRO PANEL -->\n  <div class=\"ais-panel\" id=\"ais-podcast-panel\" style=\"display:none;\">\n    <!-- RJ24-EDIT (Jul 2026): Type \/ Record mode toggle -->\n    <div class=\"ais-modebar\" id=\"pc-modebar\" style=\"display:none;\">\n      <span class=\"ais-delivery-lab\">How do you want the voice?<\/span>\n      <div class=\"ais-pills ais-modetoggle\" id=\"pc-modetoggle\">\n        <button type=\"button\" data-mode=\"type\" class=\"active\">\u2328\ufe0f Type it<\/button>\n        <button type=\"button\" data-mode=\"record\">\ud83c\udfa4 Say it yourself<\/button>\n      <\/div>\n      <p class=\"ais-mode-hint\">Type your script for an AI voice, or record it yourself and convert it into an AI voice \u2014 your own timing and delivery carry through. The music bed still plays around it.<\/p>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Intro or outro?<\/div>\n      <div class=\"ais-step-sub\">An <b>intro<\/b> opens your show (voice, then music plays out). An <b>outro<\/b> closes it (music fades in, then your voice, then fades out).<\/div>\n      <div class=\"ais-pills\" id=\"pc-mode\" style=\"margin-top:6px;\">\n        <button type=\"button\" data-pc=\"intro\" class=\"active\">Intro<\/button>\n        <button type=\"button\" data-pc=\"outro\">Outro<\/button>\n      <\/div>\n    <\/div>\n    <div class=\"ais-step\" id=\"pc-type-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Type your script<\/div>\n      <div class=\"ais-step-sub\">e.g. \"Welcome to The Marketing Show with Sarah Lane \u2014 your weekly deep dive into what's working now.\"<\/div>\n      <textarea id=\"pc-script\" data-rj-script class=\"ais-textarea\" rows=\"3\" placeholder=\"Welcome to the show\u2026\"><\/textarea>\n      <div class=\"ais-count\" id=\"pc-count\">0 words &middot; \u2248 0 sec<\/div>\n      <div class=\"ais-delivery\" style=\"margin-top:14px;\">\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Delivery<\/span>\n          <div class=\"ais-pills\" id=\"pc-delivery\">\n            <button type=\"button\" data-d=\"lively\">Lively<\/button>\n            <button type=\"button\" data-d=\"balanced\" class=\"active\">Balanced<\/button>\n            <button type=\"button\" data-d=\"subdued\">Subdued<\/button>\n          <\/div>\n        <\/div>\n        <div class=\"ais-delivery-group\">\n          <span class=\"ais-delivery-lab\">Pace<\/span>\n          <div class=\"ais-pills\" id=\"pc-pace\">\n            <button type=\"button\" data-p=\"slow\">Slower<\/button>\n            <button type=\"button\" data-p=\"normal\" class=\"active\">Normal<\/button>\n            <button type=\"button\" data-p=\"fast\">Quicker<\/button>\n          <\/div>\n        <\/div>\n      <\/div>\n      <div class=\"ais-pronounce\">\n        <label class=\"ais-pronounce-lab\" for=\"pc-pronounce\">Tricky words? <span>Tell us how to say them (optional)<\/span><\/label>\n        <input type=\"text\" id=\"pc-pronounce\" class=\"ais-input\" placeholder=\"e.g. Featherstone = Fetherstun\">\n      <\/div>\n    <\/div>\n    <!-- RJ24-EDIT (Jul 2026): record-your-own step (shown in Record mode) -->\n    <div class=\"ais-step ais-record-step\" id=\"pc-record-step\" style=\"display:none;\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Record your intro \/ outro read<\/div>\n      <div class=\"ais-record-instructions\">\n        <b>Before you record:<\/b>\n        <ul>\n          <li>\ud83c\udfa7 Record somewhere quiet \u2014 any background noise gets picked up and carried into the converted voice.<\/li>\n          <li>\ud83c\udf99\ufe0f Get close to the mic and read it the way you want it to sound \u2014 your pacing and warmth carry through.<\/li>\n          <li>\u23f1\ufe0f Keep it under <span class=\"ais-cap\">30 seconds<\/span> \u2014 recording stops automatically at the limit.<\/li>\n        <\/ul>\n      <\/div>\n      <div class=\"ais-record-box\">\n        <button type=\"button\" class=\"ais-record-btn\">&#9679;<\/button>\n        <div class=\"ais-record-status\">Tap to start recording<\/div>\n        <div class=\"ais-record-time\" style=\"display:none;\">0:00 \/ 0:30<\/div>\n        <div class=\"ais-record-preview\" style=\"display:none;\">\n          <audio class=\"ais-record-audio\" controls style=\"width:100%;\"><\/audio>\n          <div style=\"margin-top:8px;\"><button type=\"button\" class=\"ais-retry-btn ais-record-again\">Record again<\/button><\/div>\n        <\/div>\n        <div class=\"ais-record-upload\">\n          or <label style=\"text-decoration:underline;cursor:pointer;\">upload an audio file<input type=\"file\" class=\"ais-record-file\" accept=\"audio\/*\" style=\"display:none;\"><\/label> instead of recording live\n        <\/div>\n      <\/div>\n    <\/div>\n\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">3<\/span> Music<\/div>\n      <div class=\"ais-step-sub\">Plays <span id=\"pc-when\">after your intro<\/span>. Pick ours and it costs nothing.<\/div>\n      <div class=\"ais-musicmode\" id=\"pc-musicmode\">\n        <button type=\"button\" data-mm=\"library\" class=\"active\"><b>Use our music<\/b><small>1,700+ beds &middot; free<\/small><\/button>\n        <button type=\"button\" data-mm=\"composed\"><b>Composed for you<\/b><small>Written for this show &middot; costs extra<\/small><\/button>\n      <\/div>\n\n      <div id=\"pc-mm-library\" class=\"ais-mm-panel\">\n        <div class=\"ais-sfx-picker\" id=\"pc-sfx-picker\"><\/div>\n        <p class=\"ais-sfx-picker-hint\" id=\"pc-sfx-hint\"><\/p>\n      <\/div>\n\n      <div id=\"pc-mm-composed\" class=\"ais-mm-panel\" style=\"display:none;\">\n        <div class=\"ais-step-sub\" style=\"padding-left:0;\">A <span id=\"pc-seclabel\">30<\/span>-second instrumental written for this piece alone. Uses credits.<\/div>\n        <div class=\"ais-genres\" id=\"pc-genres\"><\/div>\n        <div class=\"ais-prompt-wrap\" id=\"pc-prompt-wrap\">\n          <input type=\"text\" id=\"pc-prompt\" class=\"ais-input\" placeholder=\"Describe the music \u2014 e.g. 'warm acoustic, friendly and calm'\">\n        <\/div>\n      <\/div>\n      <div class=\"ais-musiclevel\" style=\"margin-top:12px;\">\n        <span class=\"ais-delivery-lab\">Music volume under voice<\/span>\n        <div class=\"ais-pills\" id=\"pc-musiclevel\">\n          <button type=\"button\" data-m=\"low\">Low<\/button>\n          <button type=\"button\" data-m=\"medium\" class=\"active\">Medium<\/button>\n          <button type=\"button\" data-m=\"high\">High<\/button>\n        <\/div>\n      <\/div>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">4<\/span> Choose a voice<\/div>\n      <div class=\"ais-step-sub\">Search or filter by country and type, then tap \u25b6 to preview.<\/div>\n      <div class=\"ais-record-voicenote\" id=\"pc-record-voicenote\">\ud83c\udfa4 Record mode: your own delivery and timing carry through \u2014 this is the voice your recording is converted into.<\/div>\n      <div class=\"ais-voice-controls\" id=\"pc-voice-controls\"><\/div>\n      <div class=\"ais-voices\" id=\"pc-voices\"><\/div>\n      <div class=\"ais-cost\" id=\"pc-cost\" style=\"margin-top:14px;\">Cost: <b>1 credit<\/b><\/div>\n    <\/div>\n    <div class=\"ais-result\" id=\"pc-result\">\n      <div id=\"pc-result-loading\">\n        <div class=\"ais-spinner\"><\/div>\n        <div style=\"font-weight:700;\">Creating your podcast <span id=\"pc-loadkind\">intro<\/span>\u2026<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:4px;\">Recording the voice and composing the music<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:8px;font-style:italic;\">This takes around 20&ndash;30 seconds &mdash; no need to press again. It appears right here when it&rsquo;s done.<\/div>\n      <\/div>\n      <div id=\"pc-result-ready\" style=\"display:none;\">\n        <div style=\"font-weight:800;font-size:17px;color:#065F46;\">\u2713 Your podcast <span id=\"pc-readykind\">intro<\/span> is ready!<\/div>\n        <audio id=\"pc-audio\" controls><\/audio>\n        <div class=\"ais-downloads\"><a id=\"pc-dl\" class=\"ais-dl-btn\" download=\"podcast.wav\" href=\"#\">\u2b07 Download<\/a><\/div>\n        <p class=\"ais-save-note\">\ud83d\udcbe Also auto-saved as an MP3 to your <a href=\"#\" class=\"ais-gocreations\">My Creations<\/a> tab (top of this tool) for 90 days &mdash; that&rsquo;s where your downloads live, <b>not<\/b> My Account. For WAV quality, grab it above now.<\/p>\n        <!-- \u00a315 professional production upsell -->\n        <div class=\"ais-produce\" id=\"pc-produce\">\n          <div class=\"ais-produce-body\">\n            <b>Want it polished by the pros?<\/b>\n            <span><b>The AI voice stays exactly as it is<\/b> &mdash; Paul takes your generated audio into his studio and produces around it: professional music, SFX and broadcast-quality mixing. No re-recording, no swap to a human voice. <b>\u00a3<span id=\"pc-produce-fee\">15<\/span> extra.<\/b><\/span>\n          <\/div>\n          <button type=\"button\" class=\"ais-produce-btn\" id=\"pc-produce-btn\">Get RJ24 to produce it &rarr;<\/button>\n        <\/div>\n      <\/div>\n    <\/div>\n  <\/div>\n\n  <!-- SUNG JINGLE PANEL -->\n  <div class=\"ais-panel\" id=\"ais-sung-panel\" style=\"display:none;\">\n    <div style=\"background:#FFF7ED;border:1px solid #FED7AA;border-radius:12px;padding:14px 16px;margin-bottom:18px;font-size:13.5px;color:#9A3412;line-height:1.55;\">\n      <strong>How length works:<\/strong> the more you write, the longer your jingle. A station name with a short slogan makes a quick ID (around 5\u201312 seconds); add a few more lines for a fuller jingle. As a rule of thumb, aim for <strong>at least 10 words<\/strong> \u2014 much shorter than that and the singer sometimes can't generate it. If a jingle does fail, <strong>your credits are returned automatically<\/strong> so you can try again. A full stop gives a clear pause in the delivery; a comma or dash gives a shorter breath.\n    <\/div>\n    <div style=\"background:var(--cream,#F4F2EC);border:1px solid var(--line,#E2E8F0);border-radius:12px;padding:14px 16px;margin-bottom:18px;font-size:13.5px;color:var(--text,#475569);line-height:1.55;\">\n      <strong>Sung jingles work a bit differently.<\/strong> The free retakes and free production that come with <strong>adverts, sweepers, DJ drops and podcast intros<\/strong> don&rsquo;t apply here &mdash; every sung jingle costs credits, including running the same words again. If you want a choice, pick 2 or 4 below and keep your favourite; that&rsquo;s the way to get variations. And if a jingle genuinely fails to generate, your credits come straight back, as above.\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Station or business name<\/div>\n      <input type=\"text\" id=\"sg-brand\" class=\"ais-input\" maxlength=\"60\" placeholder=\"e.g. Capital FM, Joe's Pizza\">\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Slogan \/ words to sing<\/div>\n      <input type=\"text\" id=\"sg-slogan\" class=\"ais-input\" maxlength=\"600\" placeholder=\"e.g. The number one for hits \u2014 Capital FM\">\n      <p class=\"ais-hint\" id=\"sg-slogan-hint\" style=\"margin-top:6px;\">Tip: shorter, punchier slogans sing better. The AI singer fits lyrics to a melody \u2014 over ~25\u201330 words it may skip some.<\/p>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">3<\/span> Style<\/div>\n      <select id=\"sg-style\" class=\"ais-input\">\n        <option value=\"hotac\">Hot AC<\/option>\n        <option value=\"softac\">Soft AC<\/option>\n        <option value=\"mor\">MOR (Middle of the Road)<\/option>\n        <option value=\"chr\">CHR \/ Contemporary Hit<\/option>\n        <option value=\"oldies\">Oldies<\/option>\n        <option value=\"country\">Country<\/option>\n        <option value=\"classical\">Classical<\/option>\n        <option value=\"rock\">Rock<\/option>\n        <option value=\"motown\">Motown<\/option>\n        <option value=\"soul\">Soul<\/option>\n        <option value=\"reggae\">Reggae<\/option>\n        <option value=\"disco\">Disco<\/option>\n        <option value=\"rockabilly\">Rockabilly \/ 50s<\/option>\n        <option value=\"something\">Something else (describe below)<\/option>\n      <\/select>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">4<\/span> How many?<\/div>\n      <div class=\"ais-pills\" id=\"sg-qty\">\n        <button type=\"button\" class=\"ais-pill active\" data-qty=\"1\">1 jingle<\/button>\n        <button type=\"button\" class=\"ais-pill\" data-qty=\"2\">2 jingles<\/button>\n        <button type=\"button\" class=\"ais-pill\" data-qty=\"4\">4 jingles<\/button>\n      <\/div>\n      <p class=\"ais-hint\" id=\"sg-qty-hint\">Each jingle uses 10 credits &mdash; including a re-run, as there are no free redos on sung jingles.<\/p>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">5<\/span> Lead voice<\/div>\n      <div class=\"ais-pills\" id=\"sg-voice\">\n        <button type=\"button\" class=\"ais-pill active\" data-voice=\"both\">Either \/ mixed<\/button>\n        <button type=\"button\" class=\"ais-pill\" data-voice=\"f\">Female-led<\/button>\n        <button type=\"button\" class=\"ais-pill\" data-voice=\"m\">Male-led<\/button>\n      <\/div>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">6<\/span> Anything else? <span style=\"font-weight:600;color:var(--muted);\">(optional)<\/span><\/div>\n      <textarea id=\"sg-free\" class=\"ais-input\" rows=\"2\" maxlength=\"600\" placeholder=\"e.g. mention 'now playing the biggest throwbacks', upbeat, summery feel\"><\/textarea>\n      <label class=\"ais-check\" style=\"margin-top:10px;\"><input type=\"checkbox\" id=\"sg-instrumental\"> Instrumental only \u2014 a short music sting (~10 sec), no vocals<\/label>\n    <\/div>\n    <div class=\"ais-result\" id=\"sg-result\">\n      <div id=\"sg-result-inner\"><\/div>\n    <\/div>\n  <\/div>\n\n  <!-- RJ24-EDIT (Jul 2026): SAY IT YOURSELF \u2014 speech-to-speech. Record or\n       upload your own line, keep your own timing\/emotion, come back in an\n       AI voice. -->\n  <div class=\"ais-panel\" id=\"ais-sty-panel\" style=\"display:none;\">\n    <!-- RJ24-EDIT (Aug 2026): a customer who has never met speech-to-speech had\n         no way of knowing what this tab does. Say it plainly, once, at the top. -->\n    <div class=\"ais-sty-what\">\n      <div class=\"ais-sty-what-ico\">&#127908;<\/div>\n      <div>\n        <b>You perform it. A professional voice says it.<\/b>\n        <p>Read your line the way you want it to sound &mdash; your timing, your emphasis, your energy.\n        We keep every bit of that performance and swap only the voice. It&rsquo;s the fastest way to get\n        exactly the read you hear in your head without being a voice artist.<\/p>\n        <p class=\"ais-sty-what-when\"><b>Good for:<\/b> a line you keep not quite getting right by typing it,\n        an accent you want delivered a particular way, or a read you can already hear but can&rsquo;t describe.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">1<\/span> Record your line<\/div>\n      <div class=\"ais-step-sub\">Say it the way you want it to sound. Recording stops on its own at <b>60 seconds<\/b>. Cost is <b>1 credit per 30 seconds<\/b>, so a short line costs 1 and a long one costs 2. Record somewhere quiet &mdash; background noise carries through into the new voice.<\/div>\n      <div class=\"ais-record-box\">\n        <button type=\"button\" class=\"ais-record-btn\" id=\"sty-record-btn\">&#9679;<\/button>\n        <div class=\"ais-record-status\" id=\"sty-record-status\">Tap to start recording<\/div>\n        <div class=\"ais-record-time\" id=\"sty-record-time\" style=\"display:none;\">0:00 \/ 1:00<\/div>\n        <div class=\"ais-record-preview\" id=\"sty-record-preview\" style=\"display:none;\">\n          <audio id=\"sty-record-audio\" controls style=\"width:100%;\"><\/audio>\n          <div style=\"margin-top:8px;\"><button type=\"button\" class=\"ais-retry-btn\" id=\"sty-record-again\">Record again<\/button><\/div>\n        <\/div>\n        <div class=\"ais-record-upload\">\n          or <label style=\"text-decoration:underline;cursor:pointer;\">upload a recording<input type=\"file\" id=\"sty-upload\" accept=\"audio\/*\" style=\"display:none;\"><\/label> instead &mdash; MP3, WAV or M4A, from your phone or computer\n        <\/div>\n      <\/div>\n    <\/div>\n    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Choose the voice it becomes<\/div>\n      <div class=\"ais-step-sub\">Your delivery, converted into this voice. Search or filter by country and type, then tap \u25b6 to preview.<\/div>\n      <div class=\"ais-voice-controls\" id=\"sty-voice-controls\"><\/div>\n      <div class=\"ais-voices\" id=\"sty-voices\"><\/div>\n      <div class=\"ais-cost\" id=\"sty-cost\" style=\"margin-top:14px;\">Cost: <b>1 credit<\/b> <span style=\"color:#94A3B8;\">(based on recording length)<\/span><\/div>\n    <\/div>\n    <div class=\"ais-result\" id=\"sty-result\">\n      <div id=\"sty-result-loading\">\n        <div class=\"ais-spinner\"><\/div>\n        <div style=\"font-weight:700;\">Converting to the chosen voice\u2026<\/div>\n        <div style=\"font-size:12px;color:#94A3B8;margin-top:8px;font-style:italic;\">Usually 10&ndash;20 seconds. Your own timing and emotion carry through into the new voice.<\/div>\n      <\/div>\n      <div id=\"sty-result-ready\" style=\"display:none;\">\n        <div style=\"font-weight:800;font-size:17px;color:#065F46;\">\u2713 Your line is ready!<\/div>\n        <audio id=\"sty-audio\" controls><\/audio>\n        <div class=\"ais-downloads\"><a id=\"sty-dl\" class=\"ais-dl-btn\" download=\"say-it-yourself.mp3\" href=\"#\">\u2b07 Download your audio<\/a><\/div>\n        <p class=\"ais-save-note\">\ud83d\udcbe Also auto-saved as an MP3 to your <a href=\"#\" class=\"ais-gocreations\">My Creations<\/a> tab for 90 days.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"ais-how\">\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">1<\/div><h4>Say it your way<\/h4><p>Record or upload yourself reading the line, with your own energy and pacing.<\/p><\/div>\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">2<\/div><h4>Pick a voice<\/h4><p>We convert it, keeping your delivery, replacing your voice.<\/p><\/div>\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">3<\/div><h4>Keep it<\/h4><p>Download your royalty-free line, ready for air.<\/p><\/div>\n    <\/div>\n  <\/div>\n\n  <!-- v1.6.1: My Creations panel \u2014 lists customer's saved server-side creations. -->\n  <div class=\"ais-panel\" id=\"ais-creations-panel\" style=\"display:none;\">\n    <div class=\"ais-creations-head\">\n      <div>\n        <h2 class=\"ais-creations-title\">\ud83d\uddc2\ufe0f Your AI Studio creations<\/h2>\n        <p class=\"ais-creations-sub\">Auto-saved as MP3 for <b id=\"creations-retention-label\">90<\/b> days. <b id=\"creations-count-label\">0<\/b> of <b id=\"creations-cap-label\">100<\/b> saved. Want WAV quality? Download from the result panel when you create.<\/p>\n      <\/div>\n      <div class=\"ais-creations-meta\" id=\"creations-meta\"><\/div>\n    <\/div>\n    <div id=\"creations-no-credits-banner\" style=\"display:none;\"><\/div>\n    <div id=\"creations-body\">\n      <div class=\"ais-creations-loading\">Loading your creations\u2026<\/div>\n    <\/div>\n  <\/div>\n<\/div>\n\n<!-- Credits-short overlay \u2014 shown when a customer tries to create but hasn't got\n     enough credits. Gives them a clear choice (top up \/ view plans) instead of\n     the old auto-redirect that didn't ask. Scoped to #rj-ai-studio so the\n     plugin's CSS targets it correctly. -->\n<div id=\"rj-ai-studio\">\n  <!-- RJ24-EDIT (pass zv): the account panel. Two fields, no reload.\n       aria-hidden is toggled with .show so a screen reader does not read\n       a closed dialog, and the veil is inert until then. -->\n  <div class=\"ais-acct-veil\" id=\"ais-acct-veil\" role=\"dialog\" aria-modal=\"true\"\n       aria-labelledby=\"ais-acct-title\" aria-hidden=\"true\">\n    <div class=\"ais-acct\">\n      <button type=\"button\" class=\"ais-acct-x\" id=\"ais-acct-x\" aria-label=\"Close\">&times;<\/button>\n      <h3 id=\"ais-acct-title\">Make your free one<\/h3>\n      <p class=\"ais-acct-sub\" id=\"ais-acct-sub\">Everything you have set up is still here. Pop in an email and a password and it will make it now.<\/p>\n      <p class=\"ais-acct-nocard\" id=\"ais-acct-nocard\">No card. No payment details. Nothing to cancel.<\/p>\n      <div class=\"ais-acct-err\" id=\"ais-acct-err\" role=\"alert\"><\/div>\n      <form id=\"ais-acct-form\" autocomplete=\"on\" novalidate>\n        <label for=\"ais-acct-email\">Email address<\/label>\n        <input type=\"email\" id=\"ais-acct-email\" name=\"email\" autocomplete=\"email\"\n               inputmode=\"email\" required placeholder=\"you@yourstation.com\">\n        <label for=\"ais-acct-pass\" id=\"ais-acct-pass-label\">Choose a password<\/label>\n        <input type=\"password\" id=\"ais-acct-pass\" name=\"password\"\n               autocomplete=\"new-password\" required placeholder=\"At least 8 characters\">\n        <input type=\"text\" class=\"ais-acct-hp\" id=\"ais-acct-hp\" name=\"website\"\n               tabindex=\"-1\" autocomplete=\"off\" aria-hidden=\"true\">\n        <button type=\"submit\" class=\"ais-acct-go\" id=\"ais-acct-go\">Create my account &amp; make it<\/button>\n      <\/form>\n      <p class=\"ais-acct-alt\" id=\"ais-acct-alt\">Already have an account? <button type=\"button\" id=\"ais-acct-swap\">Sign in<\/button><\/p>\n      <!-- Shown only in sign-in mode. Most people with an old account here\n           registered at a checkout years ago and had the password mailed to\n           them, so \"forgotten it?\" is the likelier state than \"typed it wrong\". -->\n      <p class=\"ais-acct-alt\" id=\"ais-acct-lost\" style=\"display:none;\">\n        <a id=\"ais-acct-lost-link\" href=\"#\" rel=\"nofollow\">Forgotten your password?<\/a>\n      <\/p>\n    <\/div>\n  <\/div>\n  <div class=\"ais-credits-short\" id=\"ais-credits-short\">\n    <button type=\"button\" class=\"ais-cs-close\" id=\"ais-cs-close\" aria-label=\"Close\">&times;<\/button>\n    <h3 class=\"ais-cs-title\">Not enough credits<\/h3>\n    <p class=\"ais-cs-detail\" id=\"ais-cs-detail\">You have <b>0 credits<\/b> and this needs <b>4<\/b>.<\/p>\n    <div class=\"ais-cs-actions\">\n      <a href=\"#\" class=\"ais-cs-topup\" id=\"ais-cs-topup\">+ Top up credits<\/a>\n      <a href=\"#\" class=\"ais-cs-plans\" id=\"ais-cs-plans\">View plans<\/a>\n    <\/div>\n  <\/div>\n<\/div>\n\n<div id=\"rjais-footer\">\n  <div class=\"info\"><span class=\"lab\">AI Studio<\/span><span class=\"val\" id=\"ais-foot-val\">\u2026<\/span><span class=\"ais-foot-disc\">AI-generated \u00b7 non-refundable<\/span><\/div>\n  <div class=\"ais-foot-actions\">\n    <a href=\"#\" class=\"ais-foot-link\" id=\"ais-foot-account\" style=\"display:none;\">\ud83d\udc64 <span id=\"ais-foot-account-text\">Log in<\/span><\/a>\n    <a href=\"#\" class=\"ais-foot-link\" id=\"ais-foot-topup\" style=\"display:none;\">+ Top up<\/a>\n    <a href=\"#\" class=\"ais-foot-link solid\" id=\"ais-foot-subscribe\" style=\"display:none;\">View plans<\/a>\n    <span class=\"ais-foot-out\" id=\"ais-foot-out\">\n      <button type=\"button\" data-o=\"play\" class=\"ghost\">&#9654; <span class=\"lbl\">Play<\/span><\/button>\n      <button type=\"button\" data-o=\"seg\" class=\"ghost\">&#127911; <span class=\"lbl\">Seg Edit<\/span><\/button>\n      <button type=\"button\" data-o=\"dl\">&#11015; <span class=\"lbl\">Download<\/span><\/button>\n    <\/span>\n    <button type=\"button\" id=\"ais-create\">Create my advert<\/button>\n    <button type=\"button\" id=\"sw-create\" style=\"display:none;\">Create my sweeper<\/button>\n    <button type=\"button\" id=\"dj-create\" style=\"display:none;\">Create my DJ drop<\/button>\n    <button type=\"button\" id=\"pc-create\" style=\"display:none;\">Create my podcast audio<\/button>\n    <button type=\"button\" id=\"sg-create\" style=\"display:none;\">Create my jingle<\/button>\n    <button type=\"button\" id=\"sty-create\" style=\"display:none;\">Convert to this voice<\/button>\n    <button type=\"button\" id=\"creations-refresh\" style=\"display:none;\">Refresh<\/button>\n  <\/div>\n<\/div>\n<div id=\"rjais-toast\"><\/div>\n\n<script>\n(function(){\n  var CFG = window.RJ24_AISTUDIO_CFG || {};\n  \/\/ DEMO mode: true until the real backend (TTS + music) is wired. In demo we\n  \/\/ synthesise a short tone bed + (silent) voice slot so the flow is clickable.\n  var DEMO = (CFG.demo !== false);\n\n  var GENRES = CFG.genres || [\n    { key:'corporate', ico:'\ud83c\udfe2', label:'Corporate', desc:'Clean & professional' },\n    { key:'party',     ico:'\ud83c\udf89', label:'Party',     desc:'Upbeat & fun' },\n    { key:'classical', ico:'\ud83c\udfbb', label:'Classical', desc:'Elegant & timeless' },\n    { key:'upbeat',    ico:'\u2600\ufe0f', label:'Upbeat Pop', desc:'Bright & feel-good' },\n    { key:'custom',    ico:'\u2728', label:'Something else', desc:'Describe your own' }\n  ];\n  var VOICES = CFG.voices || [\n    { id:'voice_f1', name:'Ava',   country:'UK',  type:'Female \u00b7 Young', meta:'British \u00b7 Female', demo:'' },\n    { id:'voice_m1', name:'Ben',   country:'UK',  type:'Male \u00b7 Middle aged', meta:'British \u00b7 Male', demo:'' },\n    { id:'voice_f2', name:'Cleo',  country:'USA', type:'Female \u00b7 Young', meta:'American \u00b7 Female', demo:'' },\n    { id:'voice_m2', name:'Drew',  country:'USA', type:'Male \u00b7 Middle aged', meta:'American \u00b7 Male', demo:'' }\n  ];\n\n  var st = { genre:null, prompt:'', voice:null, withMusic:false, musicLevel:'medium', delivery:'balanced', pace:'normal', target:0, busy:false, lastUrl:null };\n  var aisTakes = null; \/\/ created lazily after the voice element exists\n  function el(id){ return document.getElementById(id); }\n\n  \/* ===================== RJ24-EDIT (Jul 2026): IN-TAB \"SAY IT YOURSELF\" =====================\n     Shared plumbing that lets the Advert \/ Sweeper \/ DJ Drop \/ Podcast tabs offer a\n     Type \/ Record toggle. In Record mode the customer records (or uploads) their own\n     line; it's converted through \/speech-to-speech into the chosen AI voice, and that\n     converted voice then feeds the SAME browser-side music\/SFX mixing the typed path\n     uses. Hard auto-stop caps keep files small and cost sane. *\/\n  var RECORD_CAPS = { advert:90, sweeper:15, djdrop:15, podcast:30 };\n\n  function fmtClock(s){ var m=Math.floor(s\/60), ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }\n\n  \/* Reusable inline recorder bound to a .ais-record-box. Handles getUserMedia +\n     MediaRecorder with a HARD stop at maxSeconds, live upload, and duration\n     measurement (so pricing\/UI can use it). Queries its controls by class within\n     boxEl, so each tab can have its own box with no id collisions. *\/\n  function createInlineRecorder(opts){\n    var boxEl = opts.boxEl, cap = opts.maxSeconds || 60;\n    var btn      = boxEl.querySelector('.ais-record-btn');\n    var status   = boxEl.querySelector('.ais-record-status');\n    var timeEl   = boxEl.querySelector('.ais-record-time');\n    var preview  = boxEl.querySelector('.ais-record-preview');\n    var pvAudio  = preview ? preview.querySelector('audio') : null;\n    var againBtn = boxEl.querySelector('.ais-record-again');\n    var fileIn   = boxEl.querySelector('input[type=file]');\n    var mediaRecorder = null, chunks = [], timer = null, startAt = 0;\n\n    function showTime(s){ if (timeEl) timeEl.textContent = fmtClock(s) + ' \/ ' + fmtClock(cap); }\n    function measureAndReady(blob){\n      var ctx = new (window.AudioContext||window.webkitAudioContext)();\n      blob.arrayBuffer().then(function(b){ return ctx.decodeAudioData(b); }).then(function(ab){\n        if (opts.onReady) opts.onReady(blob, ab.duration);\n      }).catch(function(){ if (opts.onReady) opts.onReady(blob, Math.min(cap, 8)); });\n    }\n    function done(blob){\n      if (pvAudio) pvAudio.src = URL.createObjectURL(blob);\n      if (preview) preview.style.display = 'block';\n      if (status) status.textContent = 'Recording captured \u2014 listen back, or record again';\n      if (timeEl) timeEl.style.display = 'none';\n      measureAndReady(blob);\n    }\n    function start(){\n      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){\n        toast('Recording isn\\u2019t supported in this browser \u2014 try uploading a file instead.','error'); return;\n      }\n      navigator.mediaDevices.getUserMedia({ audio:true }).then(function(stream){\n        chunks = [];\n        var mt = (window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported('audio\/webm')) ? 'audio\/webm' : '';\n        mediaRecorder = mt ? new MediaRecorder(stream, { mimeType:mt }) : new MediaRecorder(stream);\n        mediaRecorder.addEventListener('dataavailable', function(e){ if (e.data && e.data.size > 0) chunks.push(e.data); });\n        mediaRecorder.addEventListener('stop', function(){\n          stream.getTracks().forEach(function(t){ t.stop(); });\n          done(new Blob(chunks, { type: mediaRecorder.mimeType || 'audio\/webm' }));\n        });\n        mediaRecorder.start();\n        if (btn){ btn.classList.add('recording'); btn.innerHTML = '&#9632;'; }\n        if (status) status.textContent = 'Recording\u2026 tap to stop';\n        if (timeEl){ timeEl.style.display = 'block'; showTime(0); }\n        if (preview) preview.style.display = 'none';\n        startAt = Date.now();\n        timer = setInterval(function(){\n          var elapsed = (Date.now() - startAt) \/ 1000;\n          showTime(elapsed);\n          if (elapsed >= cap){ toast('Reached the ' + fmtClock(cap) + ' limit \u2014 recording stopped.'); stop(); } \/\/ hard auto-stop\n        }, 200);\n      }).catch(function(){\n        toast('Could not access your microphone \u2014 check your browser\\u2019s permission settings.','error');\n      });\n    }\n    function stop(){\n      if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();\n      clearInterval(timer);\n      if (btn){ btn.classList.remove('recording'); btn.innerHTML = '&#9679;'; }\n    }\n    if (btn) btn.addEventListener('click', function(){\n      if (mediaRecorder && mediaRecorder.state === 'recording') stop(); else start();\n    });\n    if (againBtn) againBtn.addEventListener('click', function(){\n      if (preview) preview.style.display = 'none';\n      if (status) status.textContent = 'Tap to start recording';\n      if (opts.onReset) opts.onReset();\n    });\n    if (fileIn) fileIn.addEventListener('change', function(){\n      var f = fileIn.files && fileIn.files[0];\n      if (f) done(f);\n    });\n    return { stop: stop };\n  }\n\n  \/* Convert a recorded blob to the chosen voice via \/speech-to-speech. `extra`\n     carries the tool + withMusic\/noSfx flags so the backend charges the music\/SFX\n     add-ons identically to a typed piece. Resolves to the same shape \/tts returns\n     ({ audioUrl, credits, freeFirst, musicRefundToken, ... }), so every downstream\n     step (mixing, saving, music-fail refund) is shared with the typed path. *\/\n  function stsConvert(blob, voice, seconds, extra){\n    extra = extra || {};\n    return new Promise(function(resolve, reject){\n      var reader = new FileReader();\n      reader.onload = function(){\n        var H = { 'Content-Type':'application\/json' }; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n        var body = { audio: reader.result, voice: voice, seconds: seconds || 8 };\n        for (var k in extra){ if (Object.prototype.hasOwnProperty.call(extra, k)) body[k] = extra[k]; }\n        fetch(CFG.stsUrl, { method:'POST', credentials:'same-origin', headers:H, body: JSON.stringify(body) })\n          .then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Voice conversion failed'); return j; }); })\n          .then(resolve).catch(reject);\n      };\n      reader.onerror = reject;\n      reader.readAsDataURL(blob);\n    });\n  }\n\n  \/* Wire a tab's Type \/ Record toggle. Hides itself (and forces Type mode) when\n     STS is turned off, so behaviour is byte-for-byte the old page in that case. *\/\n  function wireModeToggle(o){\n    \/\/ modeProp lets a tab store the type\/record flag under a name other than\n    \/\/ `mode` \u2014 the podcast tab already uses pc.mode for intro\/outro, so it passes\n    \/\/ modeProp:'inputMode' to keep the two separate.\n    var modeProp = o.modeProp || 'mode';\n    var bar = el(o.barId), tg = el(o.toggleId), typeStep = el(o.typeStepId), recStep = el(o.recordStepId);\n    var state = o.state;\n    state[modeProp] = 'type';\n    if (!CFG.stsEnabled){ if (bar) bar.style.display = 'none'; return null; }\n    if (bar) bar.style.display = 'block';\n    var voicenote = o.voicenoteId ? el(o.voicenoteId) : null;\n    var createBtn = o.createBtnId ? el(o.createBtnId) : null;\n    var recBox = recStep ? recStep.querySelector('.ais-record-box') : null;\n    var recorder = recBox ? createInlineRecorder({\n      boxEl: recBox, maxSeconds: o.cap,\n      onReady: function(blob, seconds){ state.recBlob = blob; state.recSeconds = seconds; if (o.onReady) o.onReady(blob, seconds); },\n      onReset: function(){ state.recBlob = null; state.recSeconds = 0; if (o.onReady) o.onReady(null, 0); }\n    }) : null;\n    state.recorder = recorder;\n    function apply(mode){\n      state[modeProp] = mode;\n      if (typeStep) typeStep.style.display = (mode==='type') ? 'block' : 'none';\n      if (recStep) recStep.style.display = (mode==='record') ? 'block' : 'none';\n      if (voicenote) voicenote.style.display = (mode==='record') ? 'block' : 'none';\n      if (createBtn){ createBtn.textContent = (mode==='record') ? (o.recordLabel||createBtn.textContent) : (o.typeLabel||createBtn.textContent); }\n      if (o.onModeChange) o.onModeChange(mode);\n    }\n    if (tg) tg.addEventListener('click', function(e){\n      var b = e.target.closest('button'); if (!b) return;\n      tg.querySelectorAll('button').forEach(function(x){ x.classList.remove('active'); });\n      b.classList.add('active');\n      apply(b.getAttribute('data-mode'));\n    });\n    apply('type');\n    return recorder;\n  }\n\n  \/* Beat pause: a full stop in the script signals a deliberate pause in the read.\n     ElevenLabs honours <break time=\"...\"\/> tags, so we insert a short break after\n     sentence-ending full stops (not decimals\/abbreviations). Keeps the imaging\n     read punchy with intentional gaps where the writer put a full stop. *\/\n  \/* RJ24-EDIT (Jul 2026, script assistant): client-side mirror of the PHP\n     auto_fix_numbers_and_urls() \u2014 runs automatically on every script as part\n     of cleanScript(), no round-trip needed for the common case. The server\n     copy in \/script-assist is the source of truth (used when the \"Improve\n     with AI\" button is pressed); this local copy just means the customer\n     sees the fix instantly as they type\/blur, not just at submit time. *\/\n  function autoFixNumbersAndUrls(text){\n    if (!text) return text;\n    var DIGIT_WORDS = ['oh','one','two','three','four','five','six','seven','eight','nine'];\n    var s = String(text);\n    \/\/ URLs \/ domains.\n    s = s.replace(\/\\b((?:www\\.)?[a-z0-9-]+(?:\\.[a-z0-9-]+)*\\.(?:co\\.uk|org\\.uk|me\\.uk|com|org|net|io|uk|co|app|shop|studio))\\b\/gi, function(m, domain){\n      domain = domain.replace(\/^www\\.\/i, '');\n      return domain.replace(\/\\.\/g, ' dot ').replace(\/-\/g, ' dash ');\n    });\n    \/\/ Phone-number-like digit runs (5+ digits, optionally space-grouped).\n    s = s.replace(\/\\b(\\d[\\d ]{4,}\\d)\\b\/g, function(m, run){\n      var digits = run.replace(\/\\s+\/g, '');\n      var words = [];\n      var i = 0;\n      while (i < digits.length){\n        if (i + 1 < digits.length && digits[i] === digits[i+1]){\n          words.push('double ' + DIGIT_WORDS[+digits[i]]);\n          i += 2;\n        } else {\n          words.push(DIGIT_WORDS[+digits[i]]);\n          i++;\n        }\n        if (words.length % 4 === 0 && i < digits.length){ words[words.length-1] += '.'; }\n      }\n      return words.join(' ');\n    });\n    return s;\n  }\n\n  \/* RJ24-EDIT (Jul 2026, Generation Sessions + multi-take): shared \"takes\"\n     picker. A generation returns a sessionToken; this lets the customer ask\n     for another performance of the SAME line\/voice \u2014 free for the first\n     CFG.freeRetakes, then a flat discounted price. Each retake only\n     regenerates the VOICE via ElevenLabs; the tool-specific `remix` callback\n     re-runs the SAME local mixing already used for the first take (music\/SFX\n     buffers already in the browser), so retakes are fast and genuinely free\n     wherever the server says they are. *\/\n  function createTakesController(opts){\n    var takes = [];\n    var active = 0;\n    var box = null;\n\n    function render(){\n      if (!box){\n        box = document.createElement('div');\n        box.className = 'ais-takes';\n        opts.hostEl.appendChild(box);\n      }\n      box.innerHTML = '<div class=\"ais-takes-h\">Takes<\/div><div class=\"ais-takes-row\"><\/div>';\n      var row = box.querySelector('.ais-takes-row');\n      takes.forEach(function(t, i){\n        var pill = document.createElement('button');\n        pill.type = 'button';\n        pill.className = 'ais-take-pill' + (i === active ? ' active' : '');\n        pill.textContent = 'Take ' + (i + 1);\n        pill.addEventListener('click', function(){ selectTake(i); });\n        row.appendChild(pill);\n      });\n      var token = opts.getSessionToken();\n      var moreBtn = document.createElement('button');\n      moreBtn.type = 'button';\n      moreBtn.className = 'ais-take-more';\n      moreBtn.textContent = '+ Try another take';\n      moreBtn.disabled = !token;\n      moreBtn.addEventListener('click', requestRetake);\n      row.appendChild(moreBtn);\n      var note = document.createElement('div');\n      note.className = 'ais-takes-note';\n      note.textContent = token ? 'Free retakes of this line \u2014 pick the one you like best.' : 'Generate again for fresh retakes (this session has expired).';\n      box.appendChild(note);\n    }\n\n    function selectTake(i){\n      active = i;\n      var t = takes[i];\n      opts.audioEl.src = t.url;\n      if (opts.dlEl) opts.dlEl.href = t.url;\n      if (opts.onSelect) opts.onSelect(t.url, t.voiceBuf, t.rawUrl);\n      render();\n    }\n\n    function requestRetake(){\n      var token = opts.getSessionToken();\n      if (!token){ toast('This session has expired \u2014 generate again for fresh retakes.'); return; }\n      var moreBtn = box.querySelector('.ais-take-more');\n      if (moreBtn){ moreBtn.disabled = true; moreBtn.textContent = 'Generating\u2026'; }\n      var H = { 'Content-Type':'application\/json' }; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n      fetch(CFG.retakeUrl, { method:'POST', credentials:'same-origin', headers:H, body: JSON.stringify({ sessionToken: token }) })\n        .then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Could not generate another take'); return j; }); })\n        .then(function(j){\n          if (typeof j.credits !== 'undefined'){ CFG.credits = j.credits; updateFooterText({}); }\n          var ctx = new (window.AudioContext||window.webkitAudioContext)();\n          return fetchBuffer(ctx, j.audioUrl).then(function(voiceBuf){\n            return opts.remix(voiceBuf).then(function(mixUrl){\n              takes.push({ url: mixUrl, voiceBuf: voiceBuf, rawUrl: j.audioUrl });\n              selectTake(takes.length - 1);\n              toast(j.charged > 0 ? ('New take \u2014 ' + j.charged + ' credit' + (j.charged===1?'':'s') + ' used.') : 'New take generated \u2014 free.');\n            });\n          });\n        })\n        .catch(function(e){ toast(e.message || 'Could not generate another take.', 'error'); })\n        .finally(function(){ if (box) render(); });\n    }\n\n    return {\n      seed: function(url, voiceBuf, rawUrl){ takes = [{ url:url, voiceBuf:voiceBuf, rawUrl:rawUrl }]; active = 0; render(); },\n      reset: function(){ takes = []; active = 0; if (box){ box.remove(); box = null; } }\n    };\n  }\n\n  function beatPause(text){\n    if(!text) return text;\n    \/\/ Add a break after a full stop that's followed by a space + capital\/end,\n    \/\/ i.e. a real sentence break \u2014 avoid \"3.5\" or \"U.S.A\".\n    \/\/ RJ24-EDIT (Aug 2026, customer feedback): 0.45s read as a hole rather than\n    \/\/ a breath - \"a full stop gives too long a gap in a jingle so it does not flow\n    \/\/ like a real human voice\". Imaging is punchy; 0.18s is a breath.\n    return text.replace(\/([^\\d])\\.(\\s+|$)\/g, '$1. <break time=\"0.18s\" \/> ').trim();\n  }\n\n  \/* RJ24-EDIT (Nov 2026): cleanScript runs BEFORE beatPause to normalize what\n     the user pasted. Hyphens get demoted to commas (TTS misreads them); we\n     ensure the script ends on a full stop so the AI delivers a closed cadence\n     rather than mid-sentence inflection; we strip stage-direction patterns\n     like (laughs), [pause] that some users paste from elsewhere \u2014 TTS will\n     read those literally. We do NOT touch phone numbers, websites or\n     frequencies \u2014 that's the customer's job per the scripting guide. *\/\n  function cleanScript(text){\n    if (!text) return text;\n    var s = String(text);\n    \/\/ Strip stage-direction-style brackets that TTS reads literally.\n    s = s.replace(\/\\([^)]{1,40}\\)\/g, '');\n    s = s.replace(\/\\[[^\\]]{1,40}\\]\/g, '');\n    \/\/ En-dash, em-dash and hyphens-between-words become commas (a comma is a\n    \/\/ short breath, a hyphen confuses TTS).\n    s = s.replace(\/\\s+[\\u2013\\u2014]\\s+\/g, ', ');\n    s = s.replace(\/(\\w)-(\\w)\/g, '$1 $2'); \/\/ word-hyphenated -> word hyphenated\n    \/\/ Collapse multiple whitespace.\n    s = s.replace(\/\\s+\/g, ' ').trim();\n    \/\/ Make sure the script terminates on a full stop \/ question \/ exclamation\n    \/\/ so the AI commits to a closed cadence (no rising-inflection mid-sentence).\n    if (s && !\/[.!?]$\/.test(s)) s = s.replace(\/[,;:]+$\/, '') + '.';\n    return s;\n  }\n\n  \/* RJ24-EDIT (Nov 2026): inject the scripting guide card above every script\n     textarea. Card content lives ONCE here \u2014 wording changes happen in one\n     place and propagate to every tool. *\/\n  function scriptingGuideHTML(opts){\n    opts = opts || {};\n    var extra = '';\n    if (opts.sung){\n      extra = '<li><span class=\"rule-h\">Frequencies<\/span><span class=\"rule-eg\"><span class=\"bad\">103.6<\/span><span class=\"good\">one oh three point six<\/span><\/span><span class=\"rule-d\">Spell out each number so the singer doesn\\'t fluff it.<\/span><\/li>';\n    }\n    return '' +\n      '<div class=\"ais-scripting-guide\">' +\n        '<div class=\"ais-scripting-guide-h\"><span class=\"ico\">\u270f\ufe0f<\/span>How to write your script for the best result<\/div>' +\n        '<p style=\"margin:0 0 4px;\">A few small writing choices change everything. <strong>Spend 30 seconds on these<\/strong> and you\\'ll get a much better take first time.<\/p>' +\n        '<details>' +\n          '<summary>Show the rules &amp; examples<\/summary>' +\n          '<ul class=\"ais-scripting-guide-rules\">' +\n            '<li><span class=\"rule-h\">Pauses &mdash; use a full stop<\/span><span class=\"rule-eg\"><span class=\"bad\">Your number one for hits - hour after hour<\/span><span class=\"good\">Your number one for hits. Hour after hour.<\/span><\/span><span class=\"rule-d\">A full stop gives a short breath between phrases. Want a longer gap? Put the line on its own. Hyphens just confuse the AI.<\/span><\/li>' +\n            '<li><span class=\"rule-h\">Phone numbers &mdash; write them as words<\/span><span class=\"rule-eg\"><span class=\"bad\">Call 01924 200 200<\/span><span class=\"good\">Call oh one nine two four. Two double oh. Two double oh.<\/span><\/span><span class=\"rule-d\">The AI reads digits as \"twenty-thousand\" otherwise. Use a full stop after the area code for a natural pause.<\/span><\/li>' +\n            '<li><span class=\"rule-h\">Websites &mdash; write them as you say them<\/span><span class=\"rule-eg\"><span class=\"bad\">radiojingles24.co.uk<\/span><span class=\"good\">radio jingles twenty four dot co dot uk<\/span><\/span><span class=\"rule-d\">For .com use \"dot com\". Always spell out the dots.<\/span><\/li>' +\n            extra +\n            '<li><span class=\"rule-h\">Avoid stage directions<\/span><span class=\"rule-eg\"><span class=\"bad\">(laughs) Hey listeners! [music in]<\/span><span class=\"good\">Hey listeners!<\/span><\/span><span class=\"rule-d\">The AI will read brackets literally. We strip them automatically but keep your script clean.<\/span><\/li>' +\n          '<\/ul>' +\n        '<\/details>' +\n      '<\/div>';\n  }\n  function injectScriptingGuides(){\n    \/\/ Find every script textarea and inject the guide just before its label\/wrap.\n    \/\/ Marked containers (data-rj-guide=\"off\") skip injection.\n    var targets = document.querySelectorAll('#rj-ai-studio textarea[data-rj-script], #rj-ai-studio .ais-script-wrap');\n    targets.forEach(function(n){\n      if (n.getAttribute('data-rj-guide') === 'off') return;\n      if (n.previousElementSibling && n.previousElementSibling.classList && n.previousElementSibling.classList.contains('ais-scripting-guide')) return;\n      var sung = (n.id||'').indexOf('sg-') === 0 || n.closest('#tab-sung');\n      var tmp = document.createElement('div'); tmp.innerHTML = scriptingGuideHTML({ sung: !!sung });\n      n.parentNode.insertBefore(tmp.firstChild, n);\n    });\n  }\n  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectScriptingGuides);\n  else injectScriptingGuides();\n\n  \/* RJ24-EDIT (Jul 2026, script assistant): \"Improve with AI\" button under a\n     script textarea. Always fixes numbers\/URLs locally first (instant, free);\n     if an Anthropic key is configured server-side it also offers an AI-polished\n     version. The customer explicitly picks \"Use this\" \u2014 nothing is ever\n     silently rewritten. *\/\n  function wireScriptAssist(textareaId, tool){\n    if (!CFG.scriptAssistEnabled) return;\n    var ta = el(textareaId); if (!ta) return;\n    var rowId = textareaId + '-assist-row';\n    if (el(rowId)) return; \/\/ already wired\n    var row = document.createElement('div');\n    row.className = 'ais-assist-row';\n    row.id = rowId;\n    row.innerHTML =\n      '<button type=\"button\" class=\"ais-assist-btn\" id=\"' + textareaId + '-assist-btn\">&#10024; Improve with AI<\/button>' +\n      '<div class=\"ais-assist-box\" id=\"' + textareaId + '-assist-box\">' +\n        '<div class=\"ais-assist-box-label\" id=\"' + textareaId + '-assist-label\">Suggested rewrite<\/div>' +\n        '<div class=\"ais-assist-box-text\" id=\"' + textareaId + '-assist-text\"><\/div>' +\n        '<div class=\"ais-assist-box-actions\">' +\n          '<button type=\"button\" class=\"ais-assist-use\" id=\"' + textareaId + '-assist-use\">Use this<\/button>' +\n          '<button type=\"button\" class=\"ais-assist-dismiss\" id=\"' + textareaId + '-assist-dismiss\">Keep mine<\/button>' +\n        '<\/div>' +\n      '<\/div>';\n    ta.parentNode.insertBefore(row, ta.nextSibling);\n\n    var btn = el(textareaId + '-assist-btn');\n    var box = el(textareaId + '-assist-box');\n    var label = el(textareaId + '-assist-label');\n    var textEl = el(textareaId + '-assist-text');\n    var suggestion = '';\n\n    btn.addEventListener('click', function(){\n      var text = ta.value.trim();\n      if (!text){ toast('Write something first.'); return; }\n      if (!CFG.loggedIn){ toast('Please log in to use the script assistant.','error'); return; }\n      btn.disabled = true; btn.textContent = 'Thinking\u2026';\n      var H = { 'Content-Type':'application\/json' }; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n      fetch(CFG.scriptAssistUrl, { method:'POST', credentials:'same-origin', headers:H, body: JSON.stringify({ text:text, tool:tool }) })\n        .then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Could not improve this script'); return j; }); })\n        .then(function(j){\n          suggestion = j.polished || j.fixed || text;\n          label.textContent = j.aiUsed ? 'AI-polished rewrite' : 'Numbers & websites spelled out';\n          textEl.textContent = suggestion;\n          box.classList.add('show');\n        })\n        .catch(function(e){ toast(e.message || 'Could not improve this script.', 'error'); })\n        .finally(function(){ btn.disabled = false; btn.textContent = '\\u2728 Improve with AI'; });\n    });\n    el(textareaId + '-assist-use').addEventListener('click', function(){\n      ta.value = suggestion;\n      ta.dispatchEvent(new Event('input', { bubbles:true }));\n      box.classList.remove('show');\n    });\n    el(textareaId + '-assist-dismiss').addEventListener('click', function(){\n      box.classList.remove('show');\n    });\n  }\n  wireScriptAssist('ais-script', 'advert');\n  wireScriptAssist('sw-script', 'sweeper');\n  wireScriptAssist('dj-script', 'djdrop');\n  wireScriptAssist('pc-script', 'podcast');\n\n  \/* ===== Form-state persistence =====\n     The login modal reloads the page after sign-in (to refresh credits). That\n     would wipe anything the customer typed. We snapshot all fields to\n     sessionStorage continuously and restore them on load, so a logged-out\n     customer can fill the form, get prompted to log in, and come back to\n     exactly what they had. (This is a real WP page, so sessionStorage is fine.) *\/\n  (function formPersistence(){\n    var KEY = 'rj24ais_form_v1';\n    var root = el('rj-ai-studio'); if (!root) return;\n    function fields(){ return root.querySelectorAll('input[type=text], input[type=number], input[type=checkbox], textarea, select'); }\n    var restored = [];\n    \/\/ Restore\n    try {\n      var saved = sessionStorage.getItem(KEY);\n      if (saved){\n        var data = JSON.parse(saved);\n        fields().forEach(function(f){\n          if (!f.id || !(f.id in data)) return;\n          if (f.type === 'checkbox'){ if (f.checked !== !!data[f.id]) { f.checked = !!data[f.id]; restored.push(f); } }\n          else { if (f.value !== data[f.id]) { f.value = data[f.id]; restored.push(f); } }\n        });\n      }\n    } catch(e){}\n    \/\/ Save (debounced) on any input\n    var t;\n    function snapshot(){\n      clearTimeout(t);\n      t = setTimeout(function(){\n        try {\n          var data = {};\n          fields().forEach(function(f){ if (!f.id) return; data[f.id] = (f.type==='checkbox') ? f.checked : f.value; });\n          sessionStorage.setItem(KEY, JSON.stringify(data));\n        } catch(e){}\n      }, 250);\n    }\n    root.addEventListener('input', snapshot);\n    root.addEventListener('change', snapshot);\n    window.RJ24AISClearForm = function(){ try{ sessionStorage.removeItem(KEY); }catch(e){} };\n    \/\/ After ALL listeners are wired (deferred to end of the call stack + a tick),\n    \/\/ fire change\/input on every restored field so JS state (st.withMusic, costs,\n    \/\/ selected pills) matches the restored DOM. Fixes the \"shows 1 credit until\n    \/\/ you toggle music\" bug after a refresh.\n    window.RJ24AISSyncRestored = function(){\n      restored.forEach(function(f){\n        f.dispatchEvent(new Event('change', { bubbles:true }));\n        f.dispatchEvent(new Event('input',  { bubbles:true }));\n      });\n    };\n  })();\n  var WPC = CFG.wordsPerCredit || 75;\n  var MUSIC_COST = CFG.musicCost || 3;\n  \/* RJ24-EDIT (Aug 2026): read the effects surcharge from the server instead of\n     assuming 1, so the quoted price follows the Free production switch. With the\n     setting at 1 the arithmetic is identical to the old max(2, base+1), because\n     base is never below 1 - so this changes nothing until the switch is on. *\/\n  var SFX_COST = (typeof CFG.sfxCost === 'number') ? CFG.sfxCost : 1;\n\n  \/* RJ24-EDIT (Jul 2026, free-first-creation): shared helper \u2014 true if THIS\n     user still has their free first-creation grant available. Read live off\n     CFG (the plugin refreshes window.RJ24_AISTUDIO_CFG.freeFirstAvailable\n     after every generation, same pattern as liveCredits()\/liveLoggedIn() in\n     the Smart Create button script). *\/\n  \/* RJ24-EDIT (pass zw): now tool-aware, because plugin v1.9.9 only lets\n     the grant be spent on the tools in CFG.freeFirstTools (sweeper and DJ\n     drop by default). Without this the advert and podcast tabs would show\n     \"Cost: FREE\" and then be charged or refused at the server, which is a\n     worse experience than never having offered it.\n\n     The tool is taken from the active tab when it is not passed: every\n     caller runs inside its own tab's code while that tab is open, and two\n     places in this file already read data-tool the same way. Fails CLOSED -\n     an unknown tab shows the ordinary price, so the surprise is always the\n     pleasant direction. *\/\n  function freeFirstToolAllowed(tool){\n    var list = CFG.freeFirstTools;\n    if (!list || !list.length) { return true; }        \/\/ no list = no restriction\n    if (!tool) {\n      var t = document.querySelector(\"#rj-ai-studio .ais-tab.active\");\n      tool = t ? t.getAttribute(\"data-tool\") : \"\";\n    }\n    if (!tool) { return false; }\n    for (var i = 0; i < list.length; i++) { if (list[i] === tool) { return true; } }\n    return false;\n  }\n  \/* RJ24-EDIT (pass zz): CFG.freeFirstAvailable is computed by the server\n     for the CURRENT USER, so it is always false when nobody is logged in -\n     which meant the one audience the free offer exists to convert was the\n     only audience never shown it. A logged-out visitor is shown the offer\n     on an allowed tool; the server still decides whether the grant is\n     actually spent, so nothing can be given away by saying so here.\n\n     If they turn out to have used their grant already, the panel refreshes\n     CFG the moment they sign in and the price corrects itself before\n     anything is generated. *\/\n  \/* RJ24-EDIT (pass zza): the free preview is capped at CFG.freeFirstMaxWords\n     (40 by default) and the server refuses anything longer. Until now the\n     page never mentioned that number anywhere, so the refusal arrived AFTER\n     the customer had registered - the worst possible moment to say no. *\/\n  function freeWordCap(){\n    var n = CFG.freeFirstMaxWords;\n    return (typeof n === \"number\" && n > 0) ? n : 0;\n  }\n  function countWords(id){\n    var elx = el(id);\n    var t = elx ? (elx.value || \"\").trim() : \"\";\n    return t ? t.split(\/\\s+\/).filter(Boolean).length : 0;\n  }\n\n  function freeFirstAvailable(tool){\n    if (!CFG.freeFirstEnabled || !freeFirstToolAllowed(tool)) { return false; }\n    if (!CFG.loggedIn) { return true; }\n    return !!CFG.freeFirstAvailable;\n  }\n  \/* Small reusable \"\ud83c\udf81 your first one's free\" banner injected above the Cost\n     line on every tool when eligible. Idempotent \u2014 safe to call on every\n     refresh without stacking duplicates. *\/\n  function renderFreeFirstNotice(costElId){\n    var c = el(costElId); if (!c) return;\n    var id = costElId + '-freefirst-notice';\n    var existing = el(id);\n    if (freeFirstAvailable()){\n      if (!existing){\n        var d = document.createElement('div');\n        d.id = id;\n        d.style.cssText = 'margin-top:6px;font-size:13px;font-weight:700;color:#059669;';\n        d.textContent = '\ud83c\udf81 Your first creation is free \u2014 this one won\\u2019t use any credits (it\\u2019ll carry a short watermark tag).';\n        c.parentNode.insertBefore(d, c.nextSibling);\n      }\n    } else if (existing) {\n      existing.remove();\n    }\n  }\n  \/* Show a small badge on a finished result when the server reports it was\n     generated under the free-first grant (response field `freeFirst: true`). *\/\n  function showFreeFirstBadge(resultReadyElId){\n    var host = el(resultReadyElId); if (!host) return;\n    var id = resultReadyElId + '-freefirst-badge';\n    if (el(id)) return; \/\/ already shown\n    var b = document.createElement('div');\n    b.id = id;\n    b.className = 'ais-freefirst-badge';\n    b.textContent = '\ud83c\udf81 Free first creation \u2014 watermarked preview';\n    host.insertBefore(b, host.firstChild.nextSibling || null);\n  }\n\n  function wps(){ return st.pace==='slow' ? 2.2 : (st.pace==='fast' ? 3.0 : 2.6); }\n  function scriptWords(){ var t=el('ais-script').value.trim(); return t? t.split(\/\\s+\/).filter(Boolean).length : 0; }\n  function estSeconds(){ var w=scriptWords(); return w ? Math.round((w \/ wps())*10)\/10 : 0; }\n  function calcCost(){\n    if (freeFirstAvailable()) return 0;\n    var base;\n    if (st.mode==='record'){ base = Math.max(1, Math.ceil((st.recSeconds||8)\/(CFG.stsSecondsPerCredit||30))); }\n    else { var w = scriptWords(); base = Math.max(1, Math.ceil((w||1)\/WPC)); }\n    \/* Price follows the MODE, not whether a bed happens to be chosen yet.\n       Our music is free either way; only composed music costs. *\/\n    if (st.musicMode !== 'composed') return base;\n    return base + MUSIC_COST;\n  }\n  function refreshCost(){\n    var base, detail;\n    if (st.mode==='record'){\n      base = Math.max(1, Math.ceil((st.recSeconds||8)\/(CFG.stsSecondsPerCredit||30)));\n      detail = base+' for ~'+Math.round(st.recSeconds||0)+'s recording'+(st.withMusic?' + '+MUSIC_COST+' for music':'');\n    } else {\n      var w = scriptWords();\n      base = Math.max(1, Math.ceil((w||1)\/WPC));\n      detail = base+' for ~'+w+' words'+(st.withMusic?' + '+MUSIC_COST+' for music':'');\n    }\n    var ourMusic = (st.musicMode !== 'composed');\n    if (ourMusic) { detail = detail.replace(\/ \\+ \\d+ for (AI )?music\/, '')\n                    + (st.musicMode === 'none' ? '' : ' \\u00b7 our music, free'); }\n    var total = ourMusic ? base : (base + MUSIC_COST);\n    var txt;\n    if (freeFirstAvailable()){\n      txt = 'Cost: <b style=\"color:#059669;\">FREE<\/b> <span style=\"color:#94A3B8;\">(your first creation \u2014 normally '+total+' credit'+(total===1?'':'s')+')<\/span>';\n    } else {\n      txt = 'Cost: <b>'+total+' credit'+(total===1?'':'s')+'<\/b>';\n      \/\/ Don't explain a breakdown of nothing before anything has been typed.\n      var typed = (st.mode === 'record') ? (st.recSeconds > 0) : (scriptWords() > 0);\n      txt += typed ? (' <span style=\"color:#94A3B8;\">('+detail+')<\/span>')\n                   : ' <span style=\"color:#94A3B8;\">\u2014 updates as you write<\/span>';\n    }\n    var c=el('ais-cost'); if(c) c.innerHTML = txt;\n    renderFreeFirstNotice('ais-cost');\n    \/\/ footer mirrors it when live\n    updateFooterText({ tool:'advert', cost: (freeFirstAvailable() ? 0 : total) });\n  }\n\n  \/**\n   * Single source of truth for the footer credit-status text. Consistent across\n   * every tool and every state (logged-out \/ out-of-credits \/ has-credits \/ demo).\n   *\/\nfunction updateFooterText(opts){\n    var f = el('ais-foot-val'); if(!f) return;\n    opts = opts || {};\n    if (DEMO) { f.textContent = 'Free to test'; return; }\n    if (CFG.freeTestingActive) { f.textContent = '\ud83e\uddea Free testing mode (admin)'; return; }\n    \/* pass zz: this used to return here, so a logged-out visitor was told\n       to log in and never told why they would want to. Falls through to\n       the free-first lines below now. *\/\n    if (!CFG.loggedIn && !freeFirstAvailable()) { f.textContent = 'Log in to start creating'; return; }\n    if (freeFirstAvailable()) {\n      f.textContent = CFG.loggedIn ? '\ud83c\udf81 Your first sweeper or DJ drop is free'\n                                   : '\ud83c\udf81 Free \u2014 no card needed';\n      return;\n    }\n    \/* Eligible, but looking at a tool the grant does not cover. Say where it\n       can be spent rather than going quiet - this is the only place a\n       customer on the advert tab would otherwise never learn it exists. *\/\n    if (CFG.freeFirstEnabled && CFG.freeFirstAvailable) { f.textContent = '\ud83c\udf81 Free sweeper or DJ drop waiting on the Sweeper tab'; return; }\n    var bal = (CFG.credits||0);\n    var balTxt = bal + ' credit' + (bal===1?'':'s');\n    if (opts.cost && bal >= opts.cost) {\n      f.textContent = balTxt + ' available \u00b7 this uses ' + opts.cost;\n    } else if (opts.cost && bal < opts.cost) {\n      f.textContent = balTxt + ' \u2014 not enough (needs ' + opts.cost + ')';\n    } else {\n      f.textContent = balTxt + ' available';\n    }\n  }\n  \/\/ portal footer\/toast to body\n  function portal(){ ['rjais-footer','rjais-toast'].forEach(function(id){ var n=el(id); if(n&&n.parentElement!==document.body) document.body.appendChild(n); }); }\n  if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',portal); else portal();\n  setTimeout(portal,800);\n\n  function toast(m,k){ var t=el('rjais-toast'); t.textContent=m; t.className=k||''; t.style.display='block'; clearTimeout(t._t); t._t=setTimeout(function(){t.style.display='none';},4200); }\n\n  \/* v1.7.0: shared voice-preview player with self-hosted fallback.\n     ElevenLabs community preview_url files are often missing, 404, or near-\n     silent. When a voice has no demo, or its demo fails to load, we ask the\n     plugin for a self-hosted preview (generated once per voice, then cached)\n     and play that instead. One preview plays at a time across the whole page. *\/\n  function fetchSelfPreview(v){\n    if (v._selfPreview) return Promise.resolve(v._selfPreview);\n    if (!CFG.voicePreviewUrl || !v || !v.id) return Promise.reject(new Error('no preview'));\n    var H = {}; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n    return fetch(CFG.voicePreviewUrl + '?voice=' + encodeURIComponent(v.id), { credentials:'same-origin', headers:H })\n      .then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error((j&&j.message)||'preview failed'); return j; }); })\n      .then(function(j){ if (j && j.audioUrl){ v._selfPreview = j.audioUrl; return j.audioUrl; } throw new Error('no preview'); });\n  }\n  var RJ24Preview = (function(){\n    var cur = null, curBtn = null;\n    function reset(btn){ if (btn) btn.innerHTML = '&#9658;'; }\n    function stop(){ if (cur){ try{ cur.pause(); cur.currentTime = 0; }catch(e){} } reset(curBtn); cur = null; curBtn = null; }\n    function playUrl(url, btn){\n      stop();\n      var a = new Audio(url);\n      cur = a; curBtn = btn; btn.innerHTML = '&#10073;&#10073;';\n      a.onended = function(){ if (curBtn === btn){ reset(btn); cur = null; curBtn = null; } };\n      a.play().catch(function(){});\n      return a;\n    }\n    function toggle(v, btn){\n      \/\/ Clicking the currently-playing button stops it.\n      if (curBtn === btn && cur && !cur.paused){ stop(); return; }\n      btn.innerHTML = '&#8230;'; \/\/ \u2026 while we resolve a URL\n      var demo = v._selfPreview || v.demo || '';\n      if (demo){\n        var a = playUrl(demo, btn);\n        a.onerror = function(){\n          \/\/ The ElevenLabs demo is broken \u2014 fall back to a self-hosted preview.\n          fetchSelfPreview(v).then(function(u){ playUrl(u, btn); })\n            .catch(function(){ reset(btn); toast('No preview available for this voice.'); });\n        };\n      } else {\n        fetchSelfPreview(v).then(function(u){ playUrl(u, btn); })\n          .catch(function(){ reset(btn); toast('No preview available for this voice.'); });\n      }\n    }\n    return { toggle: toggle, stop: stop };\n  })();\n  window.RJ24Preview = RJ24Preview;\n\n  \/**\n   * Show the \"not enough credits\" inline panel. Replaces the old auto-redirect\n   * so customers get to CHOOSE between top-up and plans, see the actual gap,\n   * and aren't herded off-page in 1.5 seconds.\n   *\/\n  function showCreditsShort(needed){\n    var p = el('ais-credits-short'); if(!p) return;\n    var have = (CFG.credits||0);\n    var detail = el('ais-cs-detail');\n    if (detail) {\n      detail.innerHTML = 'You have <b>' + have + ' credit' + (have===1?'':'s') + '<\/b> \u2014 this needs <b>' + needed + '<\/b>.';\n    }\n    \/\/ Wire the buttons fresh each time (so URLs reflect current CFG).\n    var topup = el('ais-cs-topup');\n    var plans = el('ais-cs-plans');\n    if (topup) {\n      if (CFG.topUpUrl) { topup.href = CFG.topUpUrl; topup.style.display = ''; }\n      else { topup.style.display = 'none'; }\n    }\n    if (plans) {\n      plans.href = CFG.subscribeUrl || '#';\n      plans.style.display = CFG.subscribeUrl ? '' : 'none';\n    }\n    p.classList.add('show');\n  }\n  function hideCreditsShort(){ var p = el('ais-credits-short'); if(p) p.classList.remove('show'); }\n\n  \/\/ Wire close + portal the panel to body so it sits above everything.\n  (function initCreditsShort(){\n    var c = el('ais-cs-close'); if (c) c.addEventListener('click', hideCreditsShort);\n    var p = el('ais-credits-short');\n    \/\/ Portal to body so it isn't trapped inside theme containers.\n    if (p && p.parentElement && p.parentElement.id !== document.body.id) {\n      setTimeout(function(){ document.body.appendChild(p); }, 100);\n    }\n  })();\n\n  function refreshEstimate(){\n    var w = scriptWords();\n    var s = estSeconds();\n    var c = el('ais-count'); if(c) c.innerHTML = w + ' word' + (w===1?'':'s') + ' &middot; \u2248 ' + s + ' sec';\n    var hint = el('ais-target-hint');\n    if(hint){\n      if(!st.target){ hint.textContent = ''; }\n      else {\n        var diff = Math.round(st.target - s);\n        var wordGap = Math.abs(Math.round(diff * wps()));\n        if(!w){ hint.textContent = 'Aiming for ' + st.target + 's \u2014 roughly ' + Math.round(st.target*wps()) + ' words.'; }\n        else if(Math.abs(diff) <= 2){ hint.innerHTML = '<span style=\"color:#059669;font-weight:700;\">\u2713 About right for ' + st.target + 's.<\/span>'; }\n        else if(diff > 2){ hint.innerHTML = 'Add ~' + wordGap + ' words to reach ' + st.target + 's' + (st.withMusic? ' (or we\\'ll pad the music to fill it).' : '.'); }\n        else { hint.innerHTML = '<span style=\"color:#DC2626;\">Trim ~' + wordGap + ' words<\/span> \u2014 you\\'re ~' + Math.abs(diff) + 's over ' + st.target + 's.'; }\n      }\n    }\n  }\n\n  \/\/ word counter + live cost + duration estimate\n  el('ais-script').addEventListener('input', function(){\n    refreshEstimate();\n    refreshCost();\n  });\n  \/* RJ24-EDIT (Aug 2026): one music decision. Selecting a mode reveals only the\n     panel that mode needs and drives the old checkbox behind the scenes, so\n     nothing downstream had to change. *\/\n  function wireMusicMode(prefix, state, onChange){\n    var wrap = el(prefix + '-musicmode'); if (!wrap) return;\n    state.musicMode = 'library';\n    function apply(mode){\n      state.musicMode = mode;\n      var lib = el(prefix + '-mm-library'), com = el(prefix + '-mm-composed');\n      if (lib) lib.style.display = (mode === 'library') ? 'block' : 'none';\n      if (com) com.style.display = (mode === 'composed') ? 'block' : 'none';\n      \/* RJ24-EDIT (Aug 2026): set the state DIRECTLY. Ticking the hidden\n         checkbox without dispatching its change event left st.withMusic\n         false, so choosing \"Composed for you\" quoted the same price as a\n         free library bed while the server charged for the music. *\/\n      state.withMusic = (mode === 'composed');\n      var cb = el(prefix === 'ais' ? 'ais-music' : (prefix + '-music'));\n      if (cb) { cb.checked = (mode === 'composed'); }\n      var lw = el(prefix + '-musiclevel-wrap');\n      if (lw) lw.style.display = (mode === 'none') ? 'none' : 'block';\n      wrap.querySelectorAll('button').forEach(function(b){\n        b.classList.toggle('active', b.getAttribute('data-mm') === mode);\n      });\n      if (onChange) onChange();\n    }\n    wrap.addEventListener('click', function(e){\n      var b = e.target.closest('button'); if (!b) return;\n      apply(b.getAttribute('data-mm'));\n    });\n    apply('library');\n    return apply;\n  }\n\n  \/\/ music & produce toggle\n  var musicCb = el('ais-music');\n  if(musicCb){ musicCb.addEventListener('change', function(){\n    st.withMusic=this.checked; refreshCost(); refreshEstimate();\n    var lw=el('ais-musiclevel-wrap'); if(lw) lw.style.display = this.checked ? 'block' : 'none';\n  }); }\n  \/\/ music level pills\n  var mlWrap = el('ais-musiclevel');\n  if(mlWrap){ mlWrap.addEventListener('click', function(e){ var b=e.target.closest('button'); if(!b) return; mlWrap.querySelectorAll('button').forEach(function(x){x.classList.remove('active');}); b.classList.add('active'); st.musicLevel=b.getAttribute('data-m')||'medium'; }); }\n  \/\/ target length pills\n  var tWrap = el('ais-target');\n  if(tWrap){ tWrap.addEventListener('click', function(e){ var b=e.target.closest('button'); if(!b) return; tWrap.querySelectorAll('button').forEach(function(x){x.classList.remove('active');}); b.classList.add('active'); st.target=parseInt(b.getAttribute('data-t'),10)||0; refreshEstimate(); }); }\n  \/\/ produce fee label\n  var pf=el('ais-produce-fee'); if(pf && CFG.produceFee) pf.textContent = CFG.produceFee;\n  refreshCost(); refreshEstimate();\n\n  \/\/ delivery + pace pill groups\n  function wirePills(wrapId, attr, key){\n    var w=el(wrapId); if(!w) return;\n    w.addEventListener('click', function(e){\n      var b=e.target.closest('button'); if(!b) return;\n      w.querySelectorAll('button').forEach(function(x){x.classList.remove('active');});\n      b.classList.add('active'); st[key]=b.getAttribute(attr);\n      refreshEstimate();\n    });\n  }\n  wireMusicMode('ais', st, function(){ refreshCost(); });\n  wirePills('ais-delivery','data-d','delivery');\n  wirePills('ais-pace','data-p','pace');\n\n  \/\/ genres\n  var gWrap = el('ais-genres');\n  GENRES.forEach(function(g){\n    var d=document.createElement('div'); d.className='ais-genre'; d.setAttribute('data-g',g.key);\n    d.innerHTML='<div class=\"ais-genre-ico\">'+(g.ico||'\ud83c\udfb5')+'<\/div><div class=\"ais-genre-name\">'+g.label+'<\/div><div class=\"ais-genre-desc\">'+(g.desc||'')+'<\/div>';\n    d.addEventListener('click', function(){\n      gWrap.querySelectorAll('.ais-genre').forEach(function(x){x.classList.remove('selected');});\n      d.classList.add('selected'); st.genre=g.key;\n      el('ais-prompt-wrap').classList.toggle('show', g.key==='custom');\n    });\n    gWrap.appendChild(d);\n  });\n\n  \/\/ voices \u2014 searchable + filterable + grouped by country\n  var vWrap=el('ais-voices'); var curA=null, curB=null;\n\n  \/\/ Build filter controls above the voice list\n  var ctrls = document.getElementById('ais-voice-controls');\n  function uniq(arr){ return arr.filter(function(x,i){ return x && arr.indexOf(x)===i; }); }\n  \/\/ Sort countries with UK and USA pinned to the top (this is a UK radio service,\n  \/\/ and those two have the most\/most-relevant voices), then the rest A\u2013Z.\n  function sortCountries(arr){\n    var PRI = { 'UK':0, 'USA':1 };\n    return arr.slice().sort(function(a,b){\n      var pa=(a in PRI)?PRI[a]:99, pb=(b in PRI)?PRI[b]:99;\n      if(pa!==pb) return pa-pb;\n      return a.localeCompare(b);\n    });\n  }\n  var countries = sortCountries(uniq(VOICES.map(function(v){ return v.country; })));\n  var types = uniq(VOICES.map(function(v){ return (v.type||'').split(' \u00b7 ')[0]; })).sort(); \/\/ gender-ish\n  if (ctrls){\n    var cOpts = '<option value=\"\">All countries<\/option>' + countries.map(function(c){ return '<option value=\"'+c+'\">'+c+'<\/option>'; }).join('');\n    var tOpts = '<option value=\"\">All types<\/option>' + types.map(function(t){ return '<option value=\"'+t+'\">'+t+'<\/option>'; }).join('');\n    ctrls.innerHTML =\n      '<input type=\"text\" id=\"ais-voice-search\" class=\"ais-input\" placeholder=\"Search voices\u2026\" style=\"flex:2;min-width:160px;\">' +\n      '<select id=\"ais-voice-country\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+cOpts+'<\/select>' +\n      '<select id=\"ais-voice-type\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+tOpts+'<\/select>';\n  }\n\n  function renderVoices(){\n    var q = (document.getElementById('ais-voice-search')||{}).value || '';\n    var fc = (document.getElementById('ais-voice-country')||{}).value || '';\n    var ft = (document.getElementById('ais-voice-type')||{}).value || '';\n    q = q.toLowerCase().trim();\n    vWrap.innerHTML='';\n    \/\/ Filter-first: until the user picks a country\/type or searches, show a prompt\n    \/\/ (not all voices) so the page isn't flooded.\n    if(!q && !fc && !ft){\n      vWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:14px;margin:0;padding:8px 0;\">Choose a country or type above (or search) to see voices \u2014 there are '+VOICES.length+' to pick from.<\/p>';\n      return;\n    }\n    var shown = VOICES.filter(function(v){\n      if(fc && v.country!==fc) return false;\n      if(ft && (v.type||'').split(' \u00b7 ')[0]!==ft) return false;\n      if(q && (v.name+' '+v.meta+' '+v.country).toLowerCase().indexOf(q)===-1) return false;\n      return true;\n    });\n    if(!shown.length){ vWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:13px;margin:0;\">No voices match \u2014 try clearing a filter.<\/p>'; return; }\n    var groups={};\n    shown.forEach(function(v){ var k=v.country||'Other'; (groups[k]=groups[k]||[]).push(v); });\n    sortCountries(Object.keys(groups)).forEach(function(country){\n      var head=document.createElement('div'); head.className='ais-voice-group'; head.textContent=country+' ('+groups[country].length+')';\n      vWrap.appendChild(head);\n      groups[country].forEach(function(v){ vWrap.appendChild(voiceCard(v)); });\n    });\n  }\n\n  function voiceCard(v){\n    var d=document.createElement('div'); d.className='ais-voice'; d.setAttribute('data-v',v.id);\n    if(st.voice && v.id===st.voice) d.classList.add('selected');\n    d.innerHTML='<button type=\"button\" class=\"ais-voice-play\">&#9658;<\/button><span class=\"ais-voice-meta\"><b>'+v.name+'<\/b><small>'+(v.meta||v.type||'')+'<\/small><\/span>';\n    d.addEventListener('click', function(e){\n      if(e.target.closest('.ais-voice-play')) return;\n      vWrap.querySelectorAll('.ais-voice').forEach(function(x){x.classList.remove('selected');});\n      d.classList.add('selected'); st.voice=v.id;\n    });\n    var pb=d.querySelector('.ais-voice-play');\n    pb.addEventListener('click', function(e){\n      e.preventDefault(); e.stopPropagation();\n      RJ24Preview.toggle(v, pb);\n    });\n    return d;\n  }\n\n  ['ais-voice-search','ais-voice-country','ais-voice-type'].forEach(function(id){\n    var n=document.getElementById(id); if(n){ n.addEventListener('input',renderVoices); n.addEventListener('change',renderVoices); }\n  });\n  renderVoices();\n\n  \/\/ ---- Browser-side mix: voice over music, music ducked under voice and faded out ----\n\n  \/* ======================================================================\n     PLANS - the single source of truth for where each piece sits and how\n     loud it is over time. The mixers build their graphs from these, and Seg\n     Edit builds its clips from these. Anything computed in only one of the\n     two places is a bug waiting to happen: that is exactly how the editor\n     ended up drawing a bed with no fade while the player faded it.\n\n     An envelope is a list of {t, g} in ABSOLUTE seconds, ramped linearly\n     between points - which is what linearRampToValueAtTime does, so a plan\n     can be handed straight to a GainNode or straight to a canvas.\n     ====================================================================== *\/\n  function envSeal(pts){\n    var out = [];\n    for (var i = 0; i < pts.length; i++){\n      var t = Math.max(0, pts[i].t), g = Math.max(0, pts[i].g);\n      if (!isFinite(t) || !isFinite(g)) continue;\n      if (out.length && t <= out[out.length-1].t + 0.001){ out[out.length-1].g = g; continue; }\n      out.push({ t:t, g:g });\n    }\n    if (!out.length) out.push({ t:0, g:1 });\n    if (out.length < 2) out.push({ t:out[0].t + 0.05, g:out[0].g });\n    return out;\n  }\n\n  \/* apply an absolute-time envelope to a gain node *\/\n  function envApply(gain, env){\n    gain.gain.setValueAtTime(Math.max(0.0001, env[0].g), Math.max(0, env[0].t));\n    for (var i = 1; i < env.length; i++){\n      gain.gain.linearRampToValueAtTime(Math.max(0.0001, env[i].g), Math.max(0.001, env[i].t));\n    }\n  }\n\n  \/* absolute envelope -> clip-relative, for the editor *\/\n  function envRel(env, t0){\n    return env.map(function(p){ return { t: Math.max(0, p.t - t0), g: p.g }; });\n  }\n\n  \/* Trim an envelope to a clip that is shorter than the envelope, keeping the\n     gain continuous by interpolating at the cut. *\/\n  function envClip(env, len){\n    var out = [];\n    for (var i = 0; i < env.length; i++){\n      if (env[i].t <= len){ out.push({ t:env[i].t, g:env[i].g }); continue; }\n      var prev = env[i-1];\n      if (prev){\n        var f = (len - prev.t) \/ ((env[i].t - prev.t) || 1e-6);\n        out.push({ t:len, g:prev.g + (env[i].g - prev.g) * f });\n      }\n      break;\n    }\n    if (!out.length) out.push({ t:0, g:1 });\n    if (out[out.length-1].t < len - 0.001) out.push({ t:len, g:out[out.length-1].g });\n    return out;\n  }\n\n  \/* findVoicePause is defined inside the nested IIFE that owns the sweeper \/\n     DJ tool, so it is not visible from out here. That IIFE hands it up through\n     this handle as it initialises. Guarded, so a plan asked for before the tool\n     has run simply closes on the end rather than throwing. *\/\n  var _findVoicePause = null;\n\n  var SFX_LEVELS = { low:{bed:0.45,duck:0.17}, medium:{bed:0.65,duck:0.27}, high:{bed:0.85,duck:0.38} };\n\n  function sfxPlan(voiceBuf, openBuf, stingBuf, level, closeBufIn, noSfx){\n    var vDur = voiceBuf ? voiceBuf.duration : 0;\n    var lv = SFX_LEVELS[level] || SFX_LEVELS.medium;\n\n    \/* A 12-second musical opener gets to PLAY, with the voice arriving over its\n       last beat; a half-second glitch is a stab the voice lands on top of. *\/\n    var openDur = openBuf ? openBuf.duration : 0;\n    var MUSICAL = 2.5;\n    var lead = (openDur > MUSICAL) ? Math.max(0.4, openDur - 0.8)\n                                   : Math.min(0.5, openDur ? openDur * 0.4 : 0.2);\n    var vStart = lead, vEnd = vStart + vDur;\n\n    \/* the second piece punctuates a real phrase break if the read has one *\/\n    var secondBuf = closeBufIn || null;\n    var secondDur = secondBuf ? secondBuf.duration : 0;\n    \/* The middle element owns the phrase break now, so the closer simply\n       closes: it lands at the end of the read, every time. *\/\n    var secondAt = secondBuf ? vEnd : null;\n    var secondAtClose = !!secondBuf;\n\n    var closePlay = secondBuf ? Math.min(secondDur, Math.max(2.5, vDur + 1.0)) : 0;\n    var total = noSfx ? (vEnd + 0.25)\n                      : (secondAtClose ? (vEnd + closePlay + 0.1) : (vEnd + 0.35));\n    if (secondBuf && !secondAtClose && (secondAt + secondDur + 0.05) > total){\n      total = secondAt + secondDur + 0.05;\n    }\n\n    \/* No bed. A sweeper is three elements with a voice through it, so the\n       middle piece lands at a phrase break in the read instead of playing\n       underneath the whole thing. *\/\n    var midDur = stingBuf ? stingBuf.duration : 0;\n    var midAt = null, midPlay = 0, midEnv = null;\n    if (stingBuf){\n      if (voiceBuf){\n        var midPause = _findVoicePause ? _findVoicePause(voiceBuf) : null;\n        midAt = vStart + ((midPause !== null && midPause !== undefined) ? midPause : vDur * 0.45);\n      } else {\n        midAt = vStart;\n      }\n      \/* Do not let a long element sit on top of most of the read. *\/\n      midPlay = Math.min(midDur, Math.max(1.0, vDur * 0.55));\n      midEnv = (midPlay < midDur - 0.05)\n        ? envSeal([{ t:midAt, g:1 }, { t:midAt + Math.max(0.05, midPlay - 0.3), g:1 }, { t:midAt + midPlay, g:0 }])\n        : envSeal([{ t:midAt, g:1 }, { t:midAt + midPlay, g:1 }]);\n    }\n\n    if (secondBuf){\n      total = Math.max(total, secondAt + closePlay + 0.1);\n    }\n    if (midAt !== null){\n      total = Math.max(total, midAt + midPlay + 0.1);\n    }\n\n    return { lv:lv, vDur:vDur, openDur:openDur, lead:lead, vStart:vStart, vEnd:vEnd,\n             secondBuf:secondBuf, secondDur:secondDur, secondAt:secondAt,\n             secondAtClose:secondAtClose, closePlay:closePlay, total:total,\n             midAt:midAt, midPlay:midPlay, midEnv:midEnv };\n  }\n\n  var ADV_LEVELS = { low:{bed:0.35,duck:0.07}, medium:{bed:0.55,duck:0.11}, high:{bed:0.80,duck:0.15} };\n\n  function advertPlan(voiceBuf, musicBuf, musicLevel, donut, target){\n    var voiceDur  = voiceBuf ? voiceBuf.duration : 0;\n    var intro     = 1.0;\n    var voiceStart = musicBuf ? intro : 0;\n    var SAFETY    = 1.2;\n    var voiceEnd  = voiceStart + voiceDur + SAFETY;\n    var fadeOut   = 1.0;\n    var outroBed  = (donut && musicBuf) ? 3.0 : 0;\n    var total     = voiceEnd + outroBed + fadeOut;\n    if (target && total < target) total = target;\n    var lvl = ADV_LEVELS[musicLevel] || ADV_LEVELS.medium;\n    var duckStart = voiceStart - 0.25;\n    var fadeBegin = total - fadeOut;\n\n    var musicEnv = null;\n    if (musicBuf){\n      var pts = [ { t:0, g:lvl.bed },\n                  { t:Math.max(0.01, duckStart), g:lvl.bed },\n                  { t:voiceStart + 0.2, g:lvl.duck } ];\n      if (total > voiceEnd + fadeOut + 0.2){\n        pts.push({ t:voiceEnd, g:lvl.duck });\n        pts.push({ t:voiceEnd + 0.5, g:lvl.bed });\n        pts.push({ t:fadeBegin, g:lvl.bed });\n      } else {\n        pts.push({ t:fadeBegin, g:lvl.duck });\n      }\n      pts.push({ t:total, g:0 });\n      musicEnv = envSeal(pts);\n    }\n    return { intro:intro, voiceStart:voiceStart, voiceEnd:voiceEnd, fadeOut:fadeOut,\n             fadeBegin:fadeBegin, total:total, lvl:lvl, musicEnv:musicEnv };\n  }\n\n  var POD_LEVELS = { low:{bed:0.55,duck:0.18}, medium:{bed:0.75,duck:0.27}, high:{bed:0.90,duck:0.38} };\n\n  function podcastPlan(voiceBuf, musicBuf, mode, level, tailSecs){\n    var vDur = voiceBuf ? voiceBuf.duration : 0;\n    var fade = 1.5, TAIL = tailSecs || 30;\n    var lv = POD_LEVELS[level] || POD_LEVELS.medium;\n    var vStart, vEnd, total, musicEnv = null;\n    if (mode === 'intro'){\n      vStart = 0.25; vEnd = vStart + vDur; total = vEnd + TAIL + 0.1;\n      if (musicBuf) musicEnv = envSeal([\n        { t:0, g:lv.bed },\n        { t:Math.max(0.01, vStart - 0.05), g:lv.bed },\n        { t:vStart + 0.2, g:lv.duck },\n        { t:vEnd, g:lv.duck },\n        { t:vEnd + 0.6, g:lv.bed },\n        { t:total - fade, g:lv.bed },\n        { t:total, g:0 }\n      ]);\n    } else {\n      \/* Place the read from the END of the music, not from the start of the\n         file. Asking for a 20s outro used to put the voice at the configured\n         30s tail - ten seconds after the bed had finished - so it played over\n         nothing at all. *\/\n      var musDur = musicBuf ? musicBuf.duration : 0;\n      if (musicBuf){\n        vStart = Math.max(0, Math.min(TAIL, musDur - vDur - 0.35));\n        vEnd   = vStart + vDur;\n        \/* the music has to reach the end of the read even if it is a short bed *\/\n        total  = Math.max(vEnd + 0.25, Math.min(Math.max(musDur, vEnd + 0.25), vEnd + fade + 0.1));\n      } else {\n        vStart = 0; vEnd = vDur; total = vEnd + 0.1;\n      }\n      var mIn = Math.min(1.5, Math.max(0.2, vStart * 0.5));\n      if (musicBuf) musicEnv = envSeal([\n        { t:0, g:0 },\n        { t:mIn, g:lv.bed },\n        { t:Math.max(mIn + 0.05, vStart - 0.3), g:lv.bed },\n        { t:Math.min(total, vStart + 0.2), g:lv.duck },\n        { t:Math.max(vStart + 0.25, Math.min(total - 0.05, vEnd)), g:lv.duck },\n        { t:total, g:0 }\n      ]);\n    }\n    return { vStart:vStart, vEnd:vEnd, total:total, fade:fade, lv:lv, musicEnv:musicEnv };\n  }\n\n  function mixVoiceAndMusic(voiceBuf, musicBuf){\n    voiceBuf = rj24NormaliseVoice(voiceBuf);\n    var sr = 44100;\n    \/* Every number below used to be worked out here AND again, by eye, in Seg\n       Edit's parts() - which is how the editor ended up showing a bed with no\n       fade while the player faded it. One plan, both consumers. *\/\n    var P = advertPlan(voiceBuf, musicBuf, st.musicLevel, st.donut, st.target);\n    var voiceStart = P.voiceStart;\n    var total = P.total;\n    var ctx = new (window.OfflineAudioContext||window.webkitOfflineAudioContext)(2, Math.ceil(sr*total), sr);\n\n    \/\/ Master bus + broadcast limiter so the summed mix is loud and never clips.\n    var master = ctx.createGain(); master.gain.value = 1.0;\n    var limiter = ctx.createDynamicsCompressor();\n    limiter.threshold.value = -3.0; limiter.knee.value = 0.0; limiter.ratio.value = 20.0;\n    limiter.attack.value = 0.002; limiter.release.value = 0.18;\n    master.connect(limiter).connect(ctx.destination);\n\n    if (musicBuf){\n      var mSrc = ctx.createBufferSource(); mSrc.buffer = musicBuf;\n      \/* No loop by design - the music is requested at the full advert length,\n         and looping was what caused the restart\/gap at ~30s. *\/\n      var mGain = ctx.createGain();\n      envApply(mGain, P.musicEnv);\n      mSrc.connect(mGain).connect(master);\n      mSrc.start(0);\n    }\n    if (voiceBuf){\n      var vSrc = ctx.createBufferSource(); vSrc.buffer = voiceBuf;\n      \/\/ Broadcast vocal compressor \u2014 keeps the voice loud and even, on top of the bed.\n      var vComp = ctx.createDynamicsCompressor();\n      vComp.threshold.value = -20.0; vComp.knee.value = 6.0; vComp.ratio.value = 4.0;\n      vComp.attack.value = 0.004; vComp.release.value = 0.15;\n      var vGain = ctx.createGain(); vGain.gain.value = 2.0;  \/\/ hot, even vocal \u2014 limiter catches peaks\n      vSrc.connect(vComp).connect(vGain).connect(master);\n      vSrc.start(voiceStart);\n    }\n    return ctx.startRendering().then(function(rendered){ return bufferToWavUrl(rendered); });\n  }\n\n\n  \/* ===================== LOUDNESS (v1.7.1) ==============================\n     Every number in the mixers below is fixed - fixed compressor thresholds,\n     fixed makeup gains. That only works if what arrives is a known level, and\n     TTS output is not. These two functions make it one: the voice is measured\n     and set to a known level going IN, and the finished mix is measured and\n     set to a known loudness coming OUT.\n\n     Loudness is real BS.1770 - K-weighted, block-gated - not a peak reading,\n     because peak tells you nothing about how loud something sounds. The true\n     peak ceiling is separate and always wins, so a loud mix is turned DOWN to\n     stay legal rather than clipped.\n     ==================================================================== *\/\n  var RJ24_TARGET_LUFS    = -16.0;   \/* matches TARGET_LUFS in analysis.py, so\n                                        imaging sits with the music library *\/\n  var RJ24_CEILING_DBTP   = -1.0;    \/* broadcast delivery ceiling            *\/\n  var RJ24_VOICE_RMS_DB   = -20.0;   \/* what the chain's thresholds expect    *\/\n  var RJ24_VOICE_MAX_LIFT = 18.0;    \/* never amplify a broken render further *\/\n  var RJ24_VOICE_MAX_CUT  = 12.0;\n\n  function rj24Db2Lin(db){ return Math.pow(10, db \/ 20); }\n  function rj24Lin2Db(v){ return 20 * Math.log10(Math.max(v, 1e-12)); }\n\n  \/* One RBJ biquad, applied to a copy so the source stays clean. *\/\n  function rj24Biquad(src, c){\n    var out = new Float32Array(src.length);\n    var x1 = 0, x2 = 0, y1 = 0, y2 = 0;\n    for (var i = 0; i < src.length; i++){\n      var x = src[i];\n      var y = c.b0 * x + c.b1 * x1 + c.b2 * x2 - c.a1 * y1 - c.a2 * y2;\n      x2 = x1; x1 = x; y2 = y1; y1 = y;\n      out[i] = y;\n    }\n    return out;\n  }\n\n  \/* BS.1770 stage 1: high shelf, +4dB at 1681.97Hz. Coefficients are computed\n     for the buffer's OWN sample rate - the published tables are 48k only, and\n     this page renders at 44.1k. *\/\n  function rj24ShelfCoeffs(sr){\n    var f0 = 1681.974450955533, dbGain = 3.999843853973347, Q = 0.7071752369554196;\n    var A = Math.pow(10, dbGain \/ 40);\n    var w0 = 2 * Math.PI * f0 \/ sr, cw = Math.cos(w0), sw = Math.sin(w0);\n    var alpha = sw \/ (2 * Q), sqA2a = 2 * Math.sqrt(A) * alpha;\n    var a0 =        (A + 1) - (A - 1) * cw + sqA2a;\n    return {\n      b0:  A * ((A + 1) + (A - 1) * cw + sqA2a) \/ a0,\n      b1: -2 * A * ((A - 1) + (A + 1) * cw)     \/ a0,\n      b2:  A * ((A + 1) + (A - 1) * cw - sqA2a) \/ a0,\n      a1:  2 * ((A - 1) - (A + 1) * cw)         \/ a0,\n      a2: ((A + 1) - (A - 1) * cw - sqA2a)      \/ a0\n    };\n  }\n\n  \/* BS.1770 stage 2: high pass at 38.13Hz. *\/\n  function rj24HpCoeffs(sr){\n    var f0 = 38.13547087602444, Q = 0.5003270373238773;\n    var w0 = 2 * Math.PI * f0 \/ sr, cw = Math.cos(w0), sw = Math.sin(w0);\n    var alpha = sw \/ (2 * Q), a0 = 1 + alpha;\n    return {\n      b0:  ((1 + cw) \/ 2) \/ a0,\n      b1:  (-(1 + cw))    \/ a0,\n      b2:  ((1 + cw) \/ 2) \/ a0,\n      a1:  (-2 * cw)      \/ a0,\n      a2:  (1 - alpha)    \/ a0\n    };\n  }\n\n  \/* Magnitude response of one biquad at frequency f, in dB. *\/\n  function rj24BiquadGainDb(c, f, sr){\n    var w = 2 * Math.PI * f \/ sr, cw = Math.cos(w), sw = Math.sin(w);\n    var c2 = Math.cos(2 * w), s2 = Math.sin(2 * w);\n    var nRe = c.b0 + c.b1 * cw + c.b2 * c2, nIm = -(c.b1 * sw + c.b2 * s2);\n    var dRe = 1    + c.a1 * cw + c.a2 * c2, dIm = -(c.a1 * sw + c.a2 * s2);\n    return 10 * Math.log10((nRe * nRe + nIm * nIm) \/ (dRe * dRe + dIm * dIm));\n  }\n\n  \/* BS.1770 calibrates against a 997Hz sine: 0dBFS into one channel must read\n     -3.01 LKFS, which only works if K-weighting is worth exactly +0.691dB at\n     997Hz - cancelling the -0.691 in the block formula.\n\n     The coefficients published with the standard are 48k. This page renders at\n     44.1k, so ours are designed for the actual rate, and land 0.26dB light at\n     the calibration point (measured in-browser). Rather than hard-code that\n     number - which would be wrong at any other rate - work out what the filters\n     really do at 997Hz and correct by the difference. Exact at every rate. *\/\n  function rj24LufsCal(sr){\n    var g = rj24BiquadGainDb(rj24ShelfCoeffs(sr), 997, sr) +\n            rj24BiquadGainDb(rj24HpCoeffs(sr),    997, sr);\n    return 0.691 - g;\n  }\n\n  \/* Gated integrated loudness, in LUFS. Returns null if there is nothing to\n     measure (silence, or a buffer too short for a single 400ms block). *\/\n  function rj24Lufs(buf){\n    try {\n      var sr = buf.sampleRate;\n      var blockLen = Math.floor(sr * 0.4);\n      if (!blockLen || buf.length < blockLen) return null;\n      var hop = Math.floor(blockLen \/ 4);                 \/* 75% overlap *\/\n      var shelf = rj24ShelfCoeffs(sr), hp = rj24HpCoeffs(sr);\n      var nCh = Math.min(buf.numberOfChannels, 5), ch, i;\n\n      \/* K-weight every channel once. *\/\n      var weighted = [];\n      for (ch = 0; ch < nCh; ch++){\n        weighted.push(rj24Biquad(rj24Biquad(buf.getChannelData(ch), shelf), hp));\n      }\n\n      \/* Mean square per block, summed across channels (G = 1.0 for L\/R). *\/\n      var zs = [];\n      for (var s = 0; s + blockLen <= buf.length; s += hop){\n        var z = 0;\n        for (ch = 0; ch < nCh; ch++){\n          var d = weighted[ch], acc = 0;\n          for (i = s; i < s + blockLen; i++){ acc += d[i] * d[i]; }\n          z += acc \/ blockLen;\n        }\n        zs.push(z);\n      }\n      if (!zs.length) return null;\n\n      var cal = rj24LufsCal(sr);\n      function loud(z){ return -0.691 + cal + 10 * Math.log10(Math.max(z, 1e-12)); }\n\n      \/* Absolute gate at -70 LUFS. *\/\n      var kept = zs.filter(function(z){ return loud(z) > -70; });\n      if (!kept.length) return null;\n\n      \/* Relative gate, 10 LU below the mean of what survived. *\/\n      var mean = kept.reduce(function(a, b){ return a + b; }, 0) \/ kept.length;\n      var gate = loud(mean) - 10;\n      var kept2 = kept.filter(function(z){ return loud(z) > gate; });\n      if (!kept2.length) kept2 = kept;\n\n      var mean2 = kept2.reduce(function(a, b){ return a + b; }, 0) \/ kept2.length;\n      return loud(mean2);\n    } catch(e){ return null; }\n  }\n\n  \/* True peak, estimated with 4x Catmull-Rom oversampling. Sample peak alone\n     understates what a converter or an MP3 encoder will actually produce. *\/\n  function rj24TruePeak(buf){\n    var peak = 0;\n    for (var ch = 0; ch < buf.numberOfChannels; ch++){\n      var d = buf.getChannelData(ch), n = d.length;\n      for (var i = 0; i < n; i++){\n        var p0 = d[i - 1] || 0, p1 = d[i], p2 = d[i + 1] || 0, p3 = d[i + 2] || 0;\n        if (Math.abs(p1) > peak) peak = Math.abs(p1);\n        for (var k = 1; k < 4; k++){\n          var t = k \/ 4, t2 = t * t, t3 = t2 * t;\n          var v = 0.5 * ((2 * p1) + (-p0 + p2) * t +\n                         (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +\n                         (-p0 + 3 * p1 - 3 * p2 + p3) * t3);\n          if (Math.abs(v) > peak) peak = Math.abs(v);\n        }\n      }\n    }\n    return peak;\n  }\n\n  function rj24Scale(buf, gain){\n    if (!isFinite(gain) || Math.abs(gain - 1) < 0.001) return;\n    for (var ch = 0; ch < buf.numberOfChannels; ch++){\n      var d = buf.getChannelData(ch);\n      for (var i = 0; i < d.length; i++){ d[i] *= gain; }\n    }\n  }\n\n  \/* Gated RMS of a voice take, in dBFS. Blocks more than 25dB below the\n     loudest are treated as pauses and left out, so a read with long gaps does\n     not measure as quiet. *\/\n  function rj24VoiceRmsDb(buf){\n    var sr = buf.sampleRate, blockLen = Math.max(1, Math.floor(sr * 0.05));\n    var d = buf.getChannelData(0), blocks = [], i, s;\n    for (s = 0; s + blockLen <= d.length; s += blockLen){\n      var acc = 0;\n      for (i = s; i < s + blockLen; i++){ acc += d[i] * d[i]; }\n      blocks.push(acc \/ blockLen);\n    }\n    if (!blocks.length) return null;\n    var loudest = Math.max.apply(null, blocks);\n    if (loudest <= 1e-9) return null;\n    var floor = loudest * Math.pow(10, -25 \/ 10);\n    var kept = blocks.filter(function(z){ return z >= floor; });\n    if (!kept.length) return null;\n    var mean = kept.reduce(function(a, b){ return a + b; }, 0) \/ kept.length;\n    return rj24Lin2Db(Math.sqrt(mean));\n  }\n\n  \/* Put a voice take at the level the chain's fixed thresholds expect.\n     Mutates in place and marks the buffer, so the editor's waveforms, Seg\n     Edit's live playback and the offline render all see the same voice. *\/\n  function rj24NormaliseVoice(buf){\n    if (!buf || buf.__rj24Voice) return buf;\n    try {\n      var rms = rj24VoiceRmsDb(buf);\n      if (rms !== null){\n        var db = RJ24_VOICE_RMS_DB - rms;\n        if (db >  RJ24_VOICE_MAX_LIFT) db =  RJ24_VOICE_MAX_LIFT;\n        if (db < -RJ24_VOICE_MAX_CUT)  db = -RJ24_VOICE_MAX_CUT;\n        rj24Scale(buf, rj24Db2Lin(db));\n      }\n    } catch(e){}\n    buf.__rj24Voice = true;\n    return buf;\n  }\n\n  \/* Put a finished mix on the target loudness, never above the true-peak\n     ceiling. Whichever of the two is more restrictive wins. *\/\n  function rj24NormaliseMaster(buf){\n    if (!buf || buf.__rj24Master) return buf;\n    buf.__rj24Master = true;\n    try {\n      var lufs = rj24Lufs(buf);\n      var gain = (lufs === null) ? 1 : rj24Db2Lin(RJ24_TARGET_LUFS - lufs);\n      var tp = rj24TruePeak(buf);\n      if (tp > 0){\n        var maxGain = rj24Db2Lin(RJ24_CEILING_DBTP) \/ tp;\n        if (gain > maxGain) gain = maxGain;\n      }\n      rj24Scale(buf, gain);\n    } catch(e){}\n    return buf;\n  }\n\n  function bufferToWavUrl(buf){\n    \/* Every render path in the page ends here, so this is the one place that\n       can guarantee all six tools land on the same loudness. *\/\n    rj24NormaliseMaster(buf);\n    var n=buf.numberOfChannels, len=buf.length*n*2+44, ab=new ArrayBuffer(len), view=new DataView(ab);\n    function w(o,s){ for(var i=0;i<s.length;i++) view.setUint8(o+i,s.charCodeAt(i)); }\n    var sr=buf.sampleRate, off=0;\n    w(0,'RIFF'); view.setUint32(4,len-8,true); w(8,'WAVE'); w(12,'fmt '); view.setUint32(16,16,true);\n    view.setUint16(20,1,true); view.setUint16(22,n,true); view.setUint32(24,sr,true); view.setUint32(28,sr*n*2,true);\n    view.setUint16(32,n*2,true); view.setUint16(34,16,true); w(36,'data'); view.setUint32(40,len-44,true);\n    off=44;\n    var chans=[]; for(var c=0;c<n;c++) chans.push(buf.getChannelData(c));\n    for(var i=0;i<buf.length;i++){ for(var c2=0;c2<n;c2++){ var s=Math.max(-1,Math.min(1,chans[c2][i])); view.setInt16(off,s<0?s*0x8000:s*0x7FFF,true); off+=2; } }\n    return URL.createObjectURL(new Blob([view],{type:'audio\/wav'}));\n  }\n\n  function fetchBuffer(ctx,url){ return fetch(url).then(function(r){return r.arrayBuffer();}).then(function(a){return ctx.decodeAudioData(a);}); }\n\n  \/\/ DEMO: synthesise a simple musical tone bed so the mix\/fade is audible without a backend.\n  function demoMusicBuffer(seconds){\n    var sr=44100, ctx=new (window.OfflineAudioContext||window.webkitOfflineAudioContext)(2,sr*seconds,sr);\n    var notes=[261.63,329.63,392.0,523.25]; \/\/ C E G C \u2014 pleasant pad\n    notes.forEach(function(f,i){ var o=ctx.createOscillator(); var g=ctx.createGain(); o.type='triangle'; o.frequency.value=f; g.gain.value=0.12; o.connect(g).connect(ctx.destination); o.start(i*0.0); o.stop(seconds); });\n    return ctx.startRendering();\n  }\n\n  function validate(){\n    if(st.mode==='record'){\n      if(!st.recBlob){ toast('Record or upload your line first.','error'); return false; }\n    } else if(!el('ais-script').value.trim()){ toast('Please type your advert script.','error'); return false; }\n    if(!st.voice){ toast('Please choose a voice.','error'); return false; }\n    if(st.withMusic){\n      if(!st.genre){ toast('Pick a music vibe (or turn off \"add music\").','error'); return false; }\n      if(st.genre==='custom' && !el('ais-prompt').value.trim()){ toast('Describe the music you want.','error'); return false; }\n    }\n    return true;\n  }\n\n  function showLoading(on){\n    el('ais-result').classList.add('show'); el('ais-result').classList.toggle('ready', !on);\n    el('ais-result-loading').style.display = on?'block':'none';\n    el('ais-result-ready').style.display = on?'none':'block';\n    var mm=el('ais-result-music'); if(mm) mm.style.display = st.withMusic?'inline':'none';\n    el('ais-result').scrollIntoView({behavior:'smooth', block:'center'});\n  }\n\n  function create(){\n    if(st.busy) return;\n    if(!validate()) return;\n    var cost = calcCost();\n    if(!DEMO){\n      if(!CFG.loggedIn){ toast('One step: an email and a password, and it makes it.',''); if(window.RJ24OpenLogin){ setTimeout(function(){window.RJ24OpenLogin('login');},600); } else if(CFG.accountUrl){ setTimeout(function(){window.location.href=CFG.accountUrl;},1200); } return; }\n      if((CFG.credits||0) < cost){ showCreditsShort(cost); return; }\n    }\n    st.busy=true;\n    var btn=el('ais-create'); btn.disabled=true; btn.textContent='Creating\u2026';\n    showLoading(true);\n\n    var isRec = (st.mode==='record');\n    var script = isRec ? '' : cleanScript(el('ais-script').value.trim());\n    \/\/ Estimate the FULL advert length so the music we request is always long enough\n    \/\/ (and never has to loop): 1s intro + voice + ~1.5s safety\/fade. Honour a target.\n    var voiceEst = isRec ? (st.recSeconds||8) : (script.split(\/\\s+\/).filter(Boolean).length \/ wps());\n    var fullEst = 1.0 + voiceEst + 1.5;\n    if (st.target && st.target > fullEst) fullEst = st.target;\n    var seconds = Math.max(8, Math.min(300, Math.ceil(fullEst + 3))); \/\/ +3s headroom so music outlasts the voice\n\n    var ctx = new (window.AudioContext||window.webkitAudioContext)();\n    var H = { 'Content-Type':'application\/json' };\n    if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n    var wantsMusic = !!(el('ais-music') && el('ais-music').checked); \/\/ authoritative at submit\n    \/* RJ24-EDIT (Aug 2026): a library bed replaces composed music and is free.\n       wantsMusic is what the SERVER charges on, so it must go false - otherwise\n       we would bill for music we never generated. *\/\n    var bedPick = (st.musicMode !== 'composed' && st.musicMode !== 'none' && st.pick && st.pick.bed) ? st.pick.bed : null;\n    if (bedPick) { wantsMusic = false; }\n    st.withMusic = wantsMusic;\n    var creditsAfter = null;\n    var wasFreeFirst = false;\n\n    \/\/ Voice job: typed -> \/tts (AI reads the script); recorded -> \/speech-to-speech\n    \/\/ (converts the customer's own recording into the chosen voice). Either way it\n    \/\/ resolves to { audioUrl, credits, freeFirst, musicRefundToken, ... }, so\n    \/\/ everything downstream (music, mixing, saving) is identical. Music only when the\n    \/\/ toggle is on.\n    var voiceJob;\n    if (isRec){\n      voiceJob = stsConvert(st.recBlob, st.voice, st.recSeconds, { tool:'advert', withMusic:wantsMusic, noSfx:true });\n    } else {\n      var ttsBody = JSON.stringify({ text:script, voice:st.voice, withMusic:wantsMusic, delivery:st.delivery, pace:st.pace, pronounce:(el('ais-pronounce')||{}).value||'', voiceHints:{ noBreaths:true, finalCadenceDeclarative:true } });\n      voiceJob = fetch(CFG.ttsUrl, {method:'POST',credentials:'same-origin',headers:H,body:ttsBody})\n        .then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Voice failed'); return j; }); });\n    }\n    var jobs = [ voiceJob ];\n    if (bedPick){\n      \/\/ No API call at all - the bed is a file we already serve.\n      jobs.push(Promise.resolve({ __music: bedPick.audioUrl }));\n      st.withMusic = true;   \/\/ for the mixer only; the server was told false\n    } else if (st.withMusic){\n      var musicBody = JSON.stringify({ genre:st.genre, prompt:(el('ais-prompt')||{}).value ? el('ais-prompt').value.trim() : '', seconds:seconds, partOfTts:true });\n      jobs.push(startMusic(musicBody, H).then(function(u){ return { __music:u }; }).catch(function(){ return { __musicFailed:true }; }));\n    } else {\n      jobs.push(Promise.resolve(null));\n    }\n\n    Promise.all(jobs).then(function(res){\n      var voiceUrl = res[0] && res[0].audioUrl;\n      if (res[0] && typeof res[0].credits !== 'undefined') creditsAfter = res[0].credits;\n      if (res[0] && res[0].freeFirst) { wasFreeFirst = true; CFG.freeFirstAvailable = false; }\n      st.lastUrl = voiceUrl; \/\/ store the raw voice URL for the produce upsell\n      st.sessionToken = res[0] && res[0].sessionToken || null; \/\/ RJ24-EDIT (Jul 2026): powers the takes picker\n      var musicUrl = null;\n      var mres = res[1];\n      if (mres && mres.__music) {\n        musicUrl = mres.__music;\n      } else if (mres && mres.__musicFailed) {\n        \/\/ v1.7.0: music failed but the voice is fine. Ship the voiceover on its\n        \/\/ own and refund the music surcharge using the single-use token the TTS\n        \/\/ response gave us \u2014 so the customer is never charged for music they\n        \/\/ didn't receive.\n        toast('Music couldn\\u2019t be generated this time \\u2014 here\\u2019s your voiceover on its own. You weren\\u2019t charged for music.');\n        if (res[0] && res[0].musicRefundToken && CFG.refundMusicUrl) {\n          var Hr = { 'Content-Type':'application\/json' }; if (CFG.nonce) Hr['X-WP-Nonce'] = CFG.nonce;\n          fetch(CFG.refundMusicUrl, { method:'POST', credentials:'same-origin', headers:Hr, body: JSON.stringify({ token: res[0].musicRefundToken }) })\n            .then(function(r){ return r.json(); })\n            .then(function(j){ if (j && typeof j.credits !== 'undefined'){ CFG.credits = j.credits; updateFooterText({}); } })\n            .catch(function(){});\n        }\n        st.withMusic = false; \/\/ downstream treats this as a voice-only advert\n      }\n      return Promise.all([ voiceUrl?fetchBuffer(ctx,voiceUrl):null, musicUrl?fetchBuffer(ctx,musicUrl):null ]);\n    }).then(function(bufs){\n      st.voiceBuf = bufs[0]; st.musicBuf = bufs[1]; \/\/ v1.7.0: keep decoded buffers for free re-mix\n      \/\/ v1.4.3: always make a raw \"voice only\" WAV for the secondary download\n      \/\/ (producers want this \u2014 clean source for their own processing).\n      \/\/ For the MAIN output:\n      \/\/   - With music: mixVoiceAndMusic (voice processed + music mixed in)\n      \/\/   - No music: mixVoiceOnly (voice processed through broadcast chain,\n      \/\/     so loudness matches every other tool). The OLD code used the raw\n      \/\/     passthrough as the main output here, which was ~12dB quieter than\n      \/\/     any other AI Studio output \u2014 the \"incredibly quiet advert\" bug.\n      return bufferPassthrough(bufs[0]).then(function(dryUrl){\n        st.dryUrl = dryUrl;\n        if(!bufs[1]) {\n          \/\/ No music \u2014 main output is the broadcast-processed voice.\n          return mixVoiceOnly(bufs[0]).then(function(mainUrl){\n            return { main:mainUrl, dry:dryUrl, hasMusic:false };\n          });\n        }\n        return mixVoiceAndMusic(bufs[0], bufs[1]).then(function(mixUrl){\n          return { main:mixUrl, dry:dryUrl, hasMusic:true };\n        });\n      });\n    }).then(function(out){\n      st.mixUrl=out.main;\n      el('ais-audio').src=out.main;\n      if (wasFreeFirst) showFreeFirstBadge('ais-result-ready');\n      \/* v1.6.1: save the final mixed output server-side as MP3. Non-blocking. *\/\n      if (window.RJ24SaveCreation && CFG.loggedIn) {\n        var saveMetaAd = {\n          tool: 'advert',\n          script: st.mode==='record' ? '[Recorded \u2014 Say It Yourself]' : el('ais-script').value.trim(),\n          voice_name: ((VOICES.find ? VOICES.find(function(v){return v.id===st.voice;}) : null) || {}).name || '',\n          voice_country: ((VOICES.find ? VOICES.find(function(v){return v.id===st.voice;}) : null) || {}).country || '',\n          sfx: '',\n          sting: !!st.withMusic,\n          sting_genre: st.withMusic ? (st.genre || '') : '',\n          sting_tempo: '',\n          duration: (el('ais-audio') && el('ais-audio').duration) || 0\n        };\n        \/\/ Ensure a save-status pill exists in the result panel.\n        if (!el('ais-save-pill')) {\n          var pill = document.createElement('div');\n          pill.id = 'ais-save-pill';\n          pill.className = 'ais-save-pill';\n          pill.style.display = 'none';\n          var resReady = el('ais-result-ready');\n          if (resReady) resReady.appendChild(pill);\n        }\n        \/\/ Defer slightly so the audio element has metadata for duration.\n        setTimeout(function(){\n          saveMetaAd.duration = (el('ais-audio') && el('ais-audio').duration) || saveMetaAd.duration || 0;\n          window.RJ24SaveCreation(out.main, saveMetaAd, 'ais-save-pill');\n        }, 800);\n      }\n      \/\/ Download buttons\n      var dlMain=el('ais-dl-main'), dlDry=el('ais-dl-dry');\n      if(dlMain){ dlMain.href=out.main; dlMain.textContent = out.hasMusic ? '\u2b07 Download advert (with music)' : '\u2b07 Download advert'; }\n      if(dlDry){\n        if(out.hasMusic){ dlDry.href=out.dry; dlDry.style.display='inline-block'; }\n        else { dlDry.style.display='none'; }\n      }\n      showLoading(false);\n      btn.disabled=false; btn.textContent='Create again'; st.busy=false;\n      setupRemixUI(out.hasMusic); \/\/ v1.7.0: free re-mix controls (music level + donut)\n      attachMixEditorButton({\n        resultId:'ais-result-ready', audioId:'ais-audio', dlId:'ais-dl-main',\n        onUse: function(u){ st.mixUrl = u; },\n        parts: function(){\n          var P = advertPlan(st.voiceBuf, st.musicBuf, st.musicLevel, st.donut, st.target);\n          var out = [];\n          if (st.musicBuf){\n            var mLen = Math.min(st.musicBuf.duration, P.total);\n            out.push({ id:'bed', name:'Music', buf:st.musicBuf, start:0, trimA:0, trimB:mLen,\n              env:envClip(envRel(P.musicEnv, 0), mLen) });\n          }\n          if (st.voiceBuf) out.push({ id:'voice', name:'Your voice', buf:st.voiceBuf,\n            start:P.voiceStart, trimA:0, trimB:st.voiceBuf.duration,\n            env:[{t:0,g:1},{t:st.voiceBuf.duration,g:1}] });\n          return out;\n        }\n      });\n      attachFreeMusicBar({\n        resultId:'ais-result-ready', pickerId:'ais-sfx-picker',\n        audioId:'ais-audio', dlId:'ais-dl-main',\n        isComposed: function(){ return st.musicMode === 'composed'; },\n        remix: function(){\n          var p = st.pick || {};\n          if (!st.voiceBuf) return null;\n          var ctx2 = new (window.AudioContext||window.webkitAudioContext)();\n          return libBuffer(ctx2, p.bed).then(function(bed){\n            if (bed) { st.musicBuf = bed; }\n            return st.musicBuf ? mixVoiceAndMusic(st.voiceBuf, st.musicBuf) : mixVoiceOnly(st.voiceBuf);\n          }).then(function(url){ st.mixUrl = url; return url; });\n        }\n      });\n      \/\/ RJ24-EDIT (Jul 2026, multi-take): seed the takes picker with this\n      \/\/ generation's result. remix() re-runs the SAME local mix used above\n      \/\/ (music buffer unchanged), just swapping in the retake's voice buffer.\n      \/\/ Takes (extra performances of the same line) only apply to typed pieces \u2014\n      \/\/ a fixed recording has no re-performable take, so we skip\/clear the picker.\n      if (st.mode==='record'){\n        if (aisTakes) aisTakes.reset();\n      } else {\n        if (!aisTakes) {\n          aisTakes = createTakesController({\n            key: 'ais',\n            hostEl: el('ais-result-ready'),\n            audioEl: el('ais-audio'),\n            dlEl: el('ais-dl-main'),\n            getSessionToken: function(){ return st.sessionToken; },\n            remix: function(voiceBuf){\n              st.voiceBuf = voiceBuf;\n              return st.musicBuf ? mixVoiceAndMusic(voiceBuf, st.musicBuf) : mixVoiceOnly(voiceBuf);\n            },\n            onSelect: function(url, voiceBuf, rawUrl){ st.voiceBuf = voiceBuf; st.mixUrl = url; st.lastUrl = rawUrl; }\n          });\n        }\n        aisTakes.seed(out.main, st.voiceBuf, st.lastUrl);\n      }\n      \/\/ Show the actual finished length once the player knows it.\n      var au=el('ais-audio'), lenEl=el('ais-length');\n      if(au && lenEl){\n        au.onloadedmetadata=function(){\n          var d=Math.round(au.duration*10)\/10;\n          var msg='Length: '+d+'s';\n          if(st.target){ msg += (Math.abs(d-st.target)<=0.6 ? ' \u2713 (target '+st.target+'s)' : ' (target '+st.target+'s)'); }\n          lenEl.textContent=msg;\n        };\n      }\n      if (creditsAfter!==null){ CFG.credits=creditsAfter; updateFooterText({}); }\n      refreshCost();\n    }).catch(function(e){ fail(e); });\n  }\n\n  \/\/ v1.4.3: mixVoiceOnly \u2014 applies the SAME broadcast voice chain as\n  \/\/ mixVoiceAndMusic but without any music path. Used as the MAIN output when\n  \/\/ the customer creates an advert WITHOUT music. Brings the loudness up to\n  \/\/ match sweeper\/podcast\/with-music advert (broadcast target -7 to -10dB RMS).\n  \/\/ bufferPassthrough() below is still used for the SECONDARY \"voice only\"\n  \/\/ download offered alongside a with-music mix \u2014 producers asked for that\n  \/\/ raw version so they can process themselves.\n  function mixVoiceOnly(voiceBuf){\n    if (!voiceBuf) return Promise.reject(new Error('No audio produced'));\n    voiceBuf = rj24NormaliseVoice(voiceBuf);\n    var sr = 44100;\n    var voiceDur = voiceBuf.duration;\n    var tailPad = 0.25;  \/\/ small natural tail after voice ends\n    var total = voiceDur + tailPad;\n    var ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(\n      2, Math.ceil(sr * total), sr\n    );\n\n    \/\/ Master bus + broadcast limiter \u2014 catches summed peaks, never clips.\n    var master = ctx.createGain(); master.gain.value = 1.0;\n    var limiter = ctx.createDynamicsCompressor();\n    limiter.threshold.value = -3.0;\n    limiter.knee.value      = 0.0;\n    limiter.ratio.value     = 20.0;\n    limiter.attack.value    = 0.002;\n    limiter.release.value   = 0.18;\n    master.connect(limiter).connect(ctx.destination);\n\n    \/\/ Voice chain \u2014 mirrors mixSfxTool \/ mixPodcast for consistent loudness.\n    var vSrc = ctx.createBufferSource(); vSrc.buffer = voiceBuf;\n    var hpf  = ctx.createBiquadFilter(); hpf.type = 'highpass'; hpf.frequency.value = 90;\n    var body = ctx.createBiquadFilter(); body.type = 'peaking'; body.frequency.value = 220;  body.Q.value = 0.7; body.gain.value = 2.0;\n    var pres = ctx.createBiquadFilter(); pres.type = 'peaking'; pres.frequency.value = 4200; pres.Q.value = 0.8; pres.gain.value = 3.0;\n\n    \/\/ Broadcast vocal compressor \u2014 evens dynamics, makeup brings the level up,\n    \/\/ limiter catches anything that tries to peak.\n    var vComp = ctx.createDynamicsCompressor();\n    vComp.threshold.value = -20.0;\n    vComp.knee.value      = 6.0;\n    vComp.ratio.value     = 4.0;\n    vComp.attack.value    = 0.004;\n    vComp.release.value   = 0.15;\n    var vMakeup = ctx.createGain(); vMakeup.gain.value = 2.0;\n\n    vSrc.connect(hpf).connect(body).connect(pres).connect(vComp).connect(vMakeup).connect(master);\n    vSrc.start(0);\n\n    return ctx.startRendering().then(function(r){ return bufferToWavUrl(r); });\n  }\n\n  \/\/ Voice-only: re-encode the buffer to a WAV URL (so the player + download work).\n  function bufferPassthrough(buf){\n    if(!buf) return Promise.reject(new Error('No audio produced'));\n    var sr=44100, ctx=new (window.OfflineAudioContext||window.webkitOfflineAudioContext)(buf.numberOfChannels, buf.length, buf.sampleRate||sr);\n    var s=ctx.createBufferSource(); s.buffer=buf; s.connect(ctx.destination); s.start(0);\n    return ctx.startRendering().then(function(r){ return bufferToWavUrl(r); });\n  }\n\n  function startMusic(body, headers){\n    \/\/ ElevenLabs music returns the audio SYNCHRONOUSLY: { status:'SUCCESS', audioUrl }.\n    \/\/ (The old kie.ai flow returned a taskId to poll \u2014 we keep that as a fallback.)\n    return fetch(CFG.musicUrl,{method:'POST',credentials:'same-origin',headers:headers,body:body})\n      .then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Music failed'); return j; }); })\n      .then(function(j){\n        if(j.audioUrl){ return j.audioUrl; }                 \/\/ synchronous (ElevenLabs)\n        var id=j.taskId; if(!id || id==='sync') throw new Error('Music failed \u2014 no audio returned');\n        return new Promise(function(resolve,reject){          \/\/ fallback poll\n          var tries=0;\n          (function poll(){\n            tries++;\n            fetch(CFG.musicPollUrl+'?taskId='+encodeURIComponent(id),{credentials:'same-origin',headers:headers})\n              .then(function(r){return r.json();})\n              .then(function(s){\n                if(s.status==='SUCCESS'&&s.audioUrl){ resolve(s.audioUrl); }\n                else if(s.status==='FAIL'||s.status==='ERROR'){ reject(new Error('Music failed')); }\n                else if(tries>40){ reject(new Error('Music timed out')); }\n                else setTimeout(poll,3000);\n              }).catch(reject);\n          })();\n        });\n      });\n  }\n\n  function fail(e){\n    showLoading(false); el('ais-result').classList.remove('show');\n    var btn=el('ais-create'); btn.disabled=false; btn.textContent=(st.mode==='record'?'Create from my recording':'Create my advert'); st.busy=false;\n    toast((e&&e.message)?e.message:'Could not create the advert. Please try again.','error');\n  }\n\n  \/* v1.7.0: free re-mix. After an advert is generated, the decoded voice +\n     music buffers are kept in memory, so changing the music level or toggling\n     a donut and hitting \"Re-mix\" re-renders the mix entirely in the browser \u2014\n     no API calls, no credits. The expensive AI step (the voice) is done once. *\/\n  function remixAdvert(){\n    if (!st.voiceBuf || st.busy) return;\n    st.busy = true;\n    var rbtn = el('ais-remix-btn'); if (rbtn){ rbtn.disabled = true; rbtn.textContent = 'Re-mixing\u2026'; }\n    var p = st.musicBuf ? mixVoiceAndMusic(st.voiceBuf, st.musicBuf) : mixVoiceOnly(st.voiceBuf);\n    p.then(function(url){\n      st.mixUrl = url;\n      var au = el('ais-audio'); if (au) au.src = url;\n      var dlMain = el('ais-dl-main'); if (dlMain){ dlMain.href = url; }\n      if (rbtn){ rbtn.disabled = false; rbtn.textContent = '\u21bb Re-mix (free)'; }\n      st.busy = false;\n      toast('Re-mixed \u2014 no credits used.');\n    }).catch(function(){\n      if (rbtn){ rbtn.disabled = false; rbtn.textContent = '\u21bb Re-mix (free)'; }\n      st.busy = false;\n      toast('Could not re-mix. Please try again.','error');\n    });\n  }\n\n  function setupRemixUI(hasMusic){\n    var host = el('ais-result-ready'); if (!host) return;\n    var box = el('ais-remix-box');\n    if (!box){\n      box = document.createElement('div');\n      box.id = 'ais-remix-box';\n      box.style.cssText = 'margin-top:14px;padding:14px 16px;border:1px solid #FBCFE8;background:#FDF2F8;border-radius:12px;text-align:left;';\n      box.innerHTML =\n        '<div style=\"font-weight:800;font-size:13px;color:#9D174D;margin-bottom:8px;\">\ud83c\udf9a\ufe0f Adjust the music \u2014 free, no credits used<\/div>' +\n        '<div style=\"display:flex;gap:18px;flex-wrap:wrap;align-items:flex-end;\">' +\n          '<div><div class=\"ais-delivery-lab\">Music volume<\/div><div class=\"ais-pills\" id=\"ais-remix-level\">' +\n            '<button type=\"button\" data-m=\"low\">Low<\/button>' +\n            '<button type=\"button\" data-m=\"medium\" class=\"active\">Medium<\/button>' +\n            '<button type=\"button\" data-m=\"high\">High<\/button>' +\n          '<\/div><\/div>' +\n          '<label class=\"ais-toggle\" style=\"padding:8px 12px;\"><input type=\"checkbox\" id=\"ais-remix-donut\"> <span style=\"font-size:13px;\">Donut mix \u2014 music up, voice in the middle, music back up at the end<\/span><\/label>' +\n        '<\/div>' +\n        '<button type=\"button\" id=\"ais-remix-btn\" class=\"ais-dl-btn ais-dl-secondary\" style=\"margin-top:12px;box-shadow:none;\">\u21bb Re-mix (free)<\/button>';\n      host.appendChild(box);\n      var lvl = el('ais-remix-level');\n      if (lvl) lvl.addEventListener('click', function(e){ var b=e.target.closest('button'); if(!b) return; lvl.querySelectorAll('button').forEach(function(x){x.classList.remove('active');}); b.classList.add('active'); st.musicLevel = b.getAttribute('data-m')||'medium'; });\n      var dn = el('ais-remix-donut'); if (dn) dn.addEventListener('change', function(){ st.donut = this.checked; });\n      var rb = el('ais-remix-btn'); if (rb) rb.addEventListener('click', remixAdvert);\n    }\n    box.style.display = hasMusic ? 'block' : 'none';\n    \/\/ Reflect current state on the controls.\n    var lvl2 = el('ais-remix-level');\n    if (lvl2) lvl2.querySelectorAll('button').forEach(function(x){ x.classList.toggle('active', x.getAttribute('data-m')===st.musicLevel); });\n    var dz = el('ais-remix-donut'); if (dz) dz.checked = !!st.donut;\n  }\n\n  el('ais-create').addEventListener('click', create);\n\n  \/\/ RJ24-EDIT (Jul 2026): Type \/ Record toggle for the advert tab.\n  wireModeToggle({\n    toggleId:'ais-modetoggle', barId:'ais-modebar', typeStepId:'ais-type-step', recordStepId:'ais-record-step',\n    voicenoteId:'ais-record-voicenote', createBtnId:'ais-create',\n    typeLabel:'Create my advert', recordLabel:'Create from my recording',\n    cap: RECORD_CAPS.advert, state: st,\n    onReady:function(){ refreshCost(); }, onModeChange:function(){ refreshCost(); }\n  });\n\n  \/\/ \u00a315 produce upsell -> creates a real WooCommerce order with the voice MP3\n  var prodBtn = el('ais-produce-btn');\n  if (prodBtn){\n    prodBtn.addEventListener('click', function(){\n      if(DEMO){ toast('Production orders work once AI Studio is live (out of demo mode).'); return; }\n      if(!CFG.loggedIn){ toast('Please log in first.','error'); return; }\n      if(!st.lastUrl){ toast('Create your advert first.','error'); return; }\n      prodBtn.disabled=true; prodBtn.textContent='Creating order\u2026';\n      var H={ 'Content-Type':'application\/json' }; if(CFG.nonce) H['X-WP-Nonce']=CFG.nonce;\n      fetch(CFG.produceUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({\n        tool: 'advert', voiceUrl: st.lastUrl, script: el('ais-script').value.trim(), genre: st.genre||''\n      })}).then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Could not create order'); return j; }); })\n      .then(function(j){\n        toast('Order created! Taking you to checkout\u2026');\n        if(j.payUrl) setTimeout(function(){ window.location.href=j.payUrl; }, 1200);\n      }).catch(function(e){ toast(e.message||'Could not create the order.','error'); prodBtn.disabled=false; prodBtn.textContent='Get RJ24 to produce it \u2192'; });\n    });\n  }\n\n  \/\/ On load, reflect live credit \/ login state in the footer (fixes\n  \/\/ logged-out customers landing on the page and seeing the static placeholder).\n  (function initState(){\n    updateFooterText({ tool:'advert' });\n    if (!DEMO && CFG.loggedIn) { refreshCost(); }\n  })();\n\n  \/\/ tabs: switch between all live tools\n  function switchTool(tool){\n    \/* v1.6.1: added 'creations' tab. Hidden by default for logged-out customers\n       (the loadCreations function shows an auth prompt for them). *\/\n    var panels={ advert:'ais-advert-panel', sweeper:'ais-sweeper-panel', djdrop:'ais-djdrop-panel', podcast:'ais-podcast-panel', sung:'ais-sung-panel', sty:'ais-sty-panel', creations:'ais-creations-panel' };\n    var btns={ advert:'ais-create', sweeper:'sw-create', djdrop:'dj-create', podcast:'pc-create', sung:'sg-create', sty:'sty-create', creations:'creations-refresh' };\n    document.querySelectorAll('#rj-ai-studio .ais-tab').forEach(function(x){ x.classList.toggle('active', x.getAttribute('data-tool')===tool); });\n    Object.keys(panels).forEach(function(k){ var p=el(panels[k]); if(p) p.style.display = (k===tool)?'block':'none'; var b=el(btns[k]); if(b) b.style.display=(k===tool)?'block':'none'; });\n    if(tool==='advert'){ refreshCost(); }\n    else if(tool==='sung'){ if(window.RJ24SungRefresh) window.RJ24SungRefresh(); else updateFooterText({ tool:'sung', cost:(CFG.creditsPerJingle||10) }); }\n    else if(tool==='sweeper'){ if(window.RJ24SwRefresh) window.RJ24SwRefresh(); else updateFooterText({ tool:tool }); }\n    else if(tool==='djdrop'){ if(window.RJ24DjRefresh) window.RJ24DjRefresh(); else updateFooterText({ tool:tool }); }\n    else if(tool==='podcast'){ if(window.RJ24PcRefresh) window.RJ24PcRefresh(); else updateFooterText({ tool:tool }); }\n    else if(tool==='sty'){ if(window.RJ24StyRefresh) window.RJ24StyRefresh(); else updateFooterText({ tool:tool }); }\n    else if(tool==='creations'){ if(window.RJ24LoadCreations) window.RJ24LoadCreations(); updateFooterText({ tool:'creations' }); }\n    else { updateFooterText({ tool:tool }); }\n  }\n  document.querySelectorAll('#rj-ai-studio .ais-tab').forEach(function(t){\n    t.addEventListener('click', function(){\n      var tool=t.getAttribute('data-tool');\n      if(t.classList.contains('soon')){ toast('That tool is coming soon.'); return; }\n      switchTool(tool);\n    });\n  });\n  \/\/ v1.7.2: any \"My Creations\" link in a result panel jumps straight to that tab.\n  document.addEventListener('click', function(e){\n    var a = (e.target && e.target.closest) ? e.target.closest('.ais-gocreations') : null;\n    if (a){ e.preventDefault(); if (typeof switchTool === 'function'){ switchTool('creations'); window.scrollTo({ top: 0, behavior: 'smooth' }); } }\n  });\n\n\n  \/* ===== RJ24-EDIT (Aug 2026, production library): browsable picker for the\n     whole library \u2014 intros, beds, outros, transitions, impacts, news \u2014 with\n     search, tag chips, previews, favourites and an arrangement strip.\n\n     Slots are ADVISORY. A piece's role decides which slot it lands in when you\n     press the button, and nothing more: any piece can fill any slot, because a\n     stab makes a fine closer and a separator makes a fine opener. That is the\n     main thing AI Jingle Maker cannot do \u2014 their INTRO tab pieces can only ever\n     be intros.\n\n     st.sfxSelections keeps its existing contract: an ordered array of\n     {style,file}, opener first, closer second. A bed is appended LAST so that\n     code reading pieces[0] \/ pieces[1] is unaffected. st.pickMap records which\n     index is which, for the code that does want the bed. ===== *\/\n\n  var LIB_CACHE = null;\n  var LIB_PAGE  = 24;             \/\/ rows per \"show more\" press\n  var LIB_FAV_KEY = 'rj24ais_favs';\n\n  \/* Chips grouped and named. A flat frequency-ordered list buried the radio\n     formats among textures - \"chr\" between \"beat\" and \"cinematic\" - so the one\n     thing a station actually filters by was the hardest thing to find. *\/\n  var LIB_TAG_LABELS = {\n    hotac:'Hot AC', softac:'Soft AC', mor:'MOR', chr:'CHR', country:'Country',\n    oldies:'Oldies', rock:'Rock', soul:'Soul', dance:'Dance',\n    corporate:'Corporate', news:'Newsroom', cinematic:'Cinematic'\n  };\n  var LIB_TAG_GROUPS = [\n    { label:'Style', keys:['chr','hotac','softac','mor','oldies','country','rock','soul','dance','corporate','news','cinematic'] },\n    { label:'Mood',  keys:['warm','uplifting','driving','mellow','bright','punchy','aggressive'] },\n    { label:'Type',  keys:null }   \/\/ whatever is left over\n  ];\n  function libTagLabel(t){ return LIB_TAG_LABELS[t] || t; }\n  var LIB_SLOTS = [\n    { key:'open',  label:'Opener', ico:'\u26a1',  hint:'plays before the voice' },\n    { key:'bed',   label:'Bed',    ico:'\ud83c\udfb5', hint:'plays under the voice' },\n    { key:'close', label:'Closer', ico:'\ud83c\udfc1', hint:'plays after the voice' }\n  ];\n  \/\/ Which slot a role drops into when you press the main button.\n  var LIB_ROLE_SLOT = {\n    intro:'open', stager:'open',\n    bed:'bed', music:'bed', atmos:'bed',\n    outro:'close', impact:'close',\n    transition:'open', glitch:'open', separator:'open', news:'open'\n  };\n\n  \/* One preview at a time, across the whole page. *\/\n  var LIB_AUDIO = null, LIB_AUDIO_BTN = null;\n  function libStopPreview(){\n    if (LIB_AUDIO){ try { LIB_AUDIO.pause(); LIB_AUDIO.currentTime = 0; } catch(e){} }\n    if (LIB_AUDIO_BTN){ LIB_AUDIO_BTN.innerHTML = '&#9658;'; LIB_AUDIO_BTN.classList.remove('playing'); }\n    LIB_AUDIO = null; LIB_AUDIO_BTN = null;\n  }\n  function libPreview(item, btn){\n    if (LIB_AUDIO_BTN === btn && LIB_AUDIO && !LIB_AUDIO.paused){ libStopPreview(); return; }\n    libStopPreview();\n    var a = new Audio(item.audioUrl);\n    LIB_AUDIO = a; LIB_AUDIO_BTN = btn;\n    btn.innerHTML = '&#10073;&#10073;'; btn.classList.add('playing');\n    a.addEventListener('ended', function(){ if (LIB_AUDIO_BTN === btn) libStopPreview(); });\n    a.play().catch(function(){ libStopPreview(); toast('Could not play that piece.'); });\n  }\n\n  \/* Favourites, kept between visits. *\/\n  function libFavs(){\n    try { var r = JSON.parse(localStorage.getItem(LIB_FAV_KEY) || '[]'); return Array.isArray(r) ? r : []; }\n    catch(e){ return []; }\n  }\n  function libToggleFav(id){\n    var f = libFavs(); var i = f.indexOf(id);\n    if (i === -1) { f.push(id); } else { f.splice(i, 1); }\n    try { localStorage.setItem(LIB_FAV_KEY, JSON.stringify(f)); } catch(e){}\n    return i === -1;\n  }\n\n  \/* Fetch the library. Prefers the v1.9.0 \/library endpoint; falls back to the\n     older \/sfx-library shape so the page still works if the plugin hasn't been\n     updated yet. *\/\n  function loadLibrary(){\n    if (LIB_CACHE) return Promise.resolve(LIB_CACHE);\n    var url = CFG.libraryUrl || '';\n    if (!url){ return loadLibraryLegacy(); }\n    return fetch(url, { credentials:'same-origin' })\n      .then(function(r){ return r.json(); })\n      .then(function(j){\n        if (!j || !j.items || !j.items.length) { return loadLibraryLegacy(); }\n        LIB_CACHE = normaliseLibrary(j);\n        return LIB_CACHE;\n      })\n      .catch(function(){ return loadLibraryLegacy(); });\n  }\n  function loadLibraryLegacy(){\n    if (!CFG.sfxLibraryUrl) { LIB_CACHE = { items:[], roles:[], tags:[] }; return Promise.resolve(LIB_CACHE); }\n    return fetch(CFG.sfxLibraryUrl, { credentials:'same-origin' })\n      .then(function(r){ return r.json(); })\n      .then(function(j){\n        var lib = (j && j.library) || {};\n        var items = [], roles = [];\n        Object.keys(lib).forEach(function(role){\n          var rows = lib[role] || [];\n          roles.push({ key:role, label:role.charAt(0).toUpperCase()+role.slice(1), icon:'\ud83d\udd0a', count:rows.length });\n          rows.forEach(function(it){\n            items.push({\n              id: role + '\/' + it.file, role: role, file: it.file,\n              name: String(it.file).replace(\/\\.(mp3|wav)$\/i,'').replace(\/[_-]+\/g,' '),\n              tags: [], bpm: 0, seconds: it.duration || 1, audioUrl: it.audioUrl\n            });\n          });\n        });\n        LIB_CACHE = normaliseLibrary({ items:items, roles:roles });\n        return LIB_CACHE;\n      })\n      .catch(function(){ LIB_CACHE = { items:[], roles:[], tags:[] }; return LIB_CACHE; });\n  }\n  \/* Index the tags that actually exist, so the chips can never go stale. *\/\n  function normaliseLibrary(j){\n    var items = j.items || [];\n    var counts = {};\n    items.forEach(function(it){\n      it.tags = it.tags || [];\n      it.tags.forEach(function(t){ counts[t] = (counts[t] || 0) + 1; });\n    });\n    var tags = Object.keys(counts).sort(function(a,b){ return counts[b] - counts[a] || a.localeCompare(b); });\n    return { items: items, roles: j.roles || [], tags: tags.slice(0, 24), tagCounts: counts };\n  }\n\n  \/* Keep st.sfxSelections in the shape the rest of the page expects. *\/\n  function libSyncSelections(st){\n    var order = ['open', 'close', 'bed'];   \/\/ bed LAST \u2014 see the header note\n    var sel = [], map = {};\n    order.forEach(function(slot){\n      var it = st.pick && st.pick[slot];\n      if (!it) return;\n      map[slot] = sel.length;\n      sel.push({ style: it.role, file: it.file });\n    });\n    st.sfxSelections = sel;\n    st.pickMap = map;\n  }\n\n  function libFmt(s){\n    s = Number(s) || 0;\n    return s < 10 ? (Math.round(s * 10) \/ 10) + 's' : Math.round(s) + 's';\n  }\n\n  \/* ---------------------------------------------------------------------\n     buildSfxPicker(prefix, st, onChange)\n     Signature unchanged \u2014 drop-in for the old checkbox list.\n     --------------------------------------------------------------------- *\/\n  \/* opts.slots limits which slots this instance offers. The Advert and Podcast\n     tabs pass [\"bed\"], because a bed is the only thing their mixer takes. *\/\n  function buildSfxPicker(prefix, st, onChange, opts){\n    var host = el(prefix + '-sfx-picker');\n    var hint = el(prefix + '-sfx-hint');\n    if (!host) return;\n    \/* Sweepers and DJ drops take all three slots, and their middle one is a\n       third ELEMENT, not a music bed - the format does not have beds. Advert and\n       podcast ask for the bed slot by name and keep the bed wording. *\/\n    var SLOTS = (opts && opts.slots)\n      ? LIB_SLOTS.filter(function(s){ return opts.slots.indexOf(s.key) !== -1; })\n      : LIB_SLOTS.map(function(s){\n          return s.key === 'bed'\n            ? { key:'bed', label:'Middle', ico:'\\u2728', hint:'lands at a pause in the read' }\n            : s;\n        });\n    var ONLY = (SLOTS.length === 1) ? SLOTS[0].key : null;\n\n    \/* A sweeper or a DJ drop has no bed, so the music pieces are not merely\n       re-labelled here - they are removed from this picker entirely. Every\n       list, count, chip and the Surprise me button all read POOL, so there is\n       no route left that can drop one in. Advert and podcast ask for the bed\n       slot by name, which is how we know to leave their beds alone. *\/\n    var NO_BEDS = !(opts && opts.slots);\n    var BED_ROLES = { bed:1, music:1, atmos:1 };\n    function bedFree(items){\n      return NO_BEDS ? items.filter(function(i){ return !BED_ROLES[i.role]; }) : items;\n    }\n\n    st.pick = st.pick || { open:null, bed:null, close:null };\n    st.sfxSelections = st.sfxSelections || [];\n\n    \/\/ Bed-only pickers open on the beds rather than a spread of everything.\n    \/\/ `aim` is the slot the next chosen piece drops into. It follows the\n    \/\/ first empty slot so the obvious path needs no extra taps.\n    var ui = { q:'', role:(ONLY === 'bed' ? 'bed' : ''), tags:[], favOnly:false, shown:LIB_PAGE,\n               aim:(ONLY || 'open') };\n    function firstEmpty(){\n      for (var i = 0; i < SLOTS.length; i++){ if (!st.pick[SLOTS[i].key]) return SLOTS[i].key; }\n      return SLOTS[0].key;\n    }\n    ui.aim = ONLY || firstEmpty();\n\n    host.innerHTML = '<p class=\"ais-sfx-picker-empty\">Loading the sound library\u2026<\/p>';\n\n    loadLibrary().then(function(lib){\n      if (!lib.items.length){\n        host.innerHTML = '<p class=\"ais-sfx-picker-empty\">The sound library is empty right now \u2014 your piece will be created dry (voice only, or voice + music sting).<\/p>';\n        return;\n      }\n      host.innerHTML = '';\n\n      var POOL = bedFree(lib.items);\n      var ROLES = NO_BEDS ? lib.roles.filter(function(r){ return !BED_ROLES[r.key]; }) : lib.roles;\n\n      var arr      = document.createElement('div'); arr.className = 'ais-arr';\n      var bar      = document.createElement('div'); bar.className = 'ais-lib-bar';\n      var roleWrap = document.createElement('div'); roleWrap.className = 'ais-lib-roles';\n      var tagWrap  = document.createElement('div'); tagWrap.className = 'ais-lib-tags';\n      var countEl  = document.createElement('p');   countEl.className = 'ais-lib-count';\n      var list     = document.createElement('div'); list.className = 'ais-lib-list';\n      var moreBtn  = document.createElement('button');\n      moreBtn.type = 'button'; moreBtn.className = 'ais-lib-more';\n\n      bar.innerHTML =\n        '<input type=\"text\" class=\"ais-input ais-lib-search\" placeholder=\"Search ' + POOL.length + ' pieces by name or tag\u2026\" aria-label=\"Search the sound library\">' +\n        '<button type=\"button\" class=\"ais-chip ais-lib-favbtn\">&#9825; Favourites<\/button>';\n      host.appendChild(arr);\n      host.appendChild(bar);\n      host.appendChild(roleWrap);\n      host.appendChild(tagWrap);\n      host.appendChild(countEl);\n      host.appendChild(list);\n      host.appendChild(moreBtn);\n\n      var searchEl = bar.querySelector('.ais-lib-search');\n      var favBtn   = bar.querySelector('.ais-lib-favbtn');\n\n      \/* ---- arrangement strip ---- *\/\n      function renderArrangement(){\n        arr.innerHTML = '';\n        SLOTS.forEach(function(slot){\n          var it = st.pick[slot.key];\n          var box = document.createElement('div');\n          var aimed = (ui.aim === slot.key) && SLOTS.length > 1;\n          box.className = 'ais-arr-slot' + (it ? ' filled' : '') + (aimed ? ' aimed' : '');\n          if (SLOTS.length > 1){\n            box.setAttribute('role', 'button');\n            box.setAttribute('tabindex', '0');\n            box.title = 'Choose pieces for the ' + slot.label.toLowerCase();\n            var takeAim = function(){ ui.aim = slot.key; renderArrangement(); renderList(); };\n            box.addEventListener('click', function(e){\n              if (e.target.closest('.ais-arr-x')) return;   \/\/ the remove cross\n              takeAim();\n            });\n            box.addEventListener('keydown', function(e){\n              if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); takeAim(); }\n            });\n          }\n          var lab = document.createElement('div');\n          lab.className = 'ais-arr-lab';\n          lab.innerHTML = slot.ico + ' ' + slot.label + (aimed ? ' <em>&larr; choosing<\/em>' : '');\n          if (it){\n            var x = document.createElement('button');\n            x.type = 'button'; x.className = 'ais-arr-x'; x.title = 'Remove'; x.setAttribute('aria-label', 'Remove ' + slot.label);\n            x.innerHTML = '&times;';\n            x.addEventListener('click', function(){ setSlot(slot.key, null); });\n            lab.appendChild(x);\n          }\n          box.appendChild(lab);\n          if (it){\n            var nm = document.createElement('div'); nm.className = 'ais-arr-name'; nm.textContent = it.name;\n            var mt = document.createElement('div'); mt.className = 'ais-arr-meta';\n            mt.textContent = libFmt(it.seconds) + (it.bpm ? ' \u00b7 ' + it.bpm + ' bpm' : '');\n            box.appendChild(nm); box.appendChild(mt);\n          } else {\n            var em = document.createElement('div'); em.className = 'ais-arr-empty';\n            em.textContent = 'Nothing yet \u2014 ' + slot.hint;\n            box.appendChild(em);\n          }\n          arr.appendChild(box);\n        });\n        \/* RJ24-EDIT (Aug 2026): estimate the finished length from the pieces\n           chosen plus the script, using the same rules the mixer uses. A sweeper\n           or drop wants to be under about 12 seconds; a 12-second opener on a\n           three-word line is the commonest way to get that wrong. *\/\n        (function(){\n          var sc = el(prefix + '-script');\n          var words = (sc && sc.value.trim()) ? sc.value.trim().split(\/\\s+\/).length : 0;\n          var vEst = words ? (words \/ 2.6) : 0;\n          var o = st.pick.open ? st.pick.open.seconds : 0;\n          var c = st.pick.close ? st.pick.close.seconds : 0;\n          var lead = (o > 2.5) ? Math.max(0.4, o - 0.8) : Math.min(0.5, o ? o * 0.4 : 0.2);\n          var tail = c ? Math.min(c, Math.max(2.5, vEst + 1.0)) : 0;\n          var est = lead + vEst + tail;\n          if (!st.pick.open && !st.pick.bed && !st.pick.close) return;\n          var line = document.createElement('div');\n          line.style.cssText = 'grid-column:1\/-1;font-size:12.5px;color:#64748B;padding-top:2px;';\n          var longish = est > 12;\n          line.innerHTML = 'Finished length: <b style=\"color:' + (longish ? '#B45309' : '#1E293B') + ';\">about ' +\n            (est < 10 ? (Math.round(est * 10) \/ 10) : Math.round(est)) + ' seconds<\/b>' +\n            (words ? '' : ' <span style=\"color:#94A3B8;\">(write your line for a better estimate)<\/span>') +\n            (longish ? ' &mdash; that is long for a sweeper or drop. A shorter opener is usually the fix.' : '');\n          arr.appendChild(line);\n        })();\n\n        if (SLOTS.length > 1){\n          var how = document.createElement('div');\n          how.style.cssText = 'grid-column:1\/-1;font-size:12.5px;color:#64748B;';\n          how.innerHTML = 'Tap <b>' + SLOTS.map(function(s){ return s.label; }).join('<\/b>, <b>')\n            + '<\/b> above to aim, then press '\n            + '<b>Use as&hellip;<\/b> on any piece below. Tap &times; to empty a slot.';\n          arr.appendChild(how);\n        }\n\n        var actions = document.createElement('div');\n        actions.className = 'ais-arr-actions';\n        var surprise = document.createElement('button');\n        surprise.type = 'button'; surprise.className = 'ais-arr-btn';\n        surprise.innerHTML = '&#127922; Surprise me';\n        surprise.addEventListener('click', surpriseMe);\n        var clear = document.createElement('button');\n        clear.type = 'button'; clear.className = 'ais-arr-btn';\n        clear.textContent = 'Clear all';\n        clear.addEventListener('click', function(){ st.pick = { open:null, bed:null, close:null }; commit(); });\n        actions.appendChild(surprise);\n        actions.appendChild(clear);\n        var free = document.createElement('span');\n        free.className = 'ais-arr-free';\n        free.textContent = (CFG.freeProduction || CFG.sfxCost === 0) ? 'Production is free' : 'Mix as many as you like';\n        actions.appendChild(free);\n        arr.appendChild(actions);\n      }\n\n      function surpriseMe(){\n        function pick(pred){\n          var pool = POOL.filter(pred);\n          if (!pool.length) return null;\n          return pool[Math.floor(Math.random() * pool.length)];\n        }\n        function has(k){ return SLOTS.some(function(s){ return s.key === k; }); }\n        if (has('open'))  st.pick.open  = pick(function(i){ return LIB_ROLE_SLOT[i.role] === 'open' && i.seconds <= 4; }) || st.pick.open;\n        if (has('bed')){\n          \/* On a sweeper or drop the middle slot is an ELEMENT that lands in a\n             pause, so pick something short - never a bed, which is not in POOL\n             here anyway. Advert and podcast still get a real bed. *\/\n          st.pick.bed = (NO_BEDS\n            ? pick(function(i){ return i.seconds <= 6 && i !== st.pick.open && i !== st.pick.close; })\n            : pick(function(i){ return i.role === 'bed'; })) || st.pick.bed;\n        }\n        if (has('close')) st.pick.close = pick(function(i){ return LIB_ROLE_SLOT[i.role] === 'close' || i.role === 'separator'; }) || st.pick.close;\n        commit();\n      }\n\n      function setSlot(slot, item){\n        st.pick[slot] = item;\n        commit();\n      }\n      function commit(){\n        libSyncSelections(st);\n        renderArrangement();\n        renderList();\n        refreshSfxHint(st, hint);\n        if (onChange) onChange();\n      }\n\n      \/* ---- filters ---- *\/\n      function renderRoles(){\n        roleWrap.innerHTML = '';\n        \/* A single-slot picker has nothing to switch between - showing every\n           role just invites someone to put an impact under their advert. *\/\n        if (ONLY) { roleWrap.style.display = 'none'; return; }\n        var all = document.createElement('button');\n        all.type = 'button';\n        all.className = 'ais-chip' + (ui.role === '' ? ' on' : '');\n        all.innerHTML = 'All <small>' + POOL.length + '<\/small>';\n        all.addEventListener('click', function(){ ui.role = ''; ui.shown = LIB_PAGE; renderRoles(); renderList(); });\n        roleWrap.appendChild(all);\n        ROLES.forEach(function(r){\n          var n = 0;\n          POOL.forEach(function(i){ if (i.role === r.key) n++; });\n          if (!n) return;\n          r = { key:r.key, label:r.label, icon:r.icon, count:n };\n          var b = document.createElement('button');\n          b.type = 'button';\n          b.className = 'ais-chip' + (ui.role === r.key ? ' on' : '');\n          b.innerHTML = (r.icon || '\ud83d\udd0a') + ' ' + r.label + ' <small>' + r.count + '<\/small>';\n          b.addEventListener('click', function(){\n            ui.role = (ui.role === r.key) ? '' : r.key; ui.shown = LIB_PAGE; renderRoles(); renderList();\n          });\n          roleWrap.appendChild(b);\n        });\n      }\n      function renderTags(){\n        tagWrap.innerHTML = '';\n        \/* Chips come from the pieces this picker can actually use. A bed picker\n           offering \"closer\", \"button\" and \"impact\" is offering dead ends. *\/\n        var pool = ONLY ? POOL.filter(function(i){ return i.role === ONLY; }) : POOL;\n        var counts = {};\n        pool.forEach(function(i){ (i.tags || []).forEach(function(t){ counts[t] = (counts[t] || 0) + 1; }); });\n        var present = Object.keys(counts);\n        var claimed = {};\n        LIB_TAG_GROUPS.forEach(function(g){\n          var keys;\n          if (g.keys) {\n            keys = g.keys.filter(function(k){ return counts[k]; });\n            keys.forEach(function(k){ claimed[k] = 1; });\n          } else {\n            \/\/ whatever no earlier group claimed, commonest first, trimmed\n            keys = present.filter(function(k){ return !claimed[k]; })\n                          .sort(function(a,b){ return counts[b]-counts[a] || a.localeCompare(b); })\n                          .slice(0, 10);\n          }\n          if (!keys.length) { return; }\n          var row = document.createElement('div');\n          row.className = 'ais-lib-taggroup';\n          var lab = document.createElement('span');\n          lab.className = 'ais-lib-taglabel';\n          lab.textContent = g.label;\n          row.appendChild(lab);\n          keys.forEach(function(t){ row.appendChild(tagChip(t, counts[t])); });\n          tagWrap.appendChild(row);\n        });\n      }\n\n      function tagChip(t, n){\n        return (function(){\n          var b = document.createElement('button');\n          b.type = 'button';\n          b.className = 'ais-chip ais-chip-tag' + (ui.tags.indexOf(t) !== -1 ? ' on' : '');\n          b.innerHTML = libTagLabel(t) + ' <small>' + n + '<\/small>';\n          b.addEventListener('click', function(){\n            var i = ui.tags.indexOf(t);\n            if (i === -1) { ui.tags.push(t); } else { ui.tags.splice(i, 1); }\n            ui.shown = LIB_PAGE; renderTags(); renderList();\n          });\n          return b;\n        })();\n      }\n\n      function filtered(){\n        var favs = libFavs();\n        var q = ui.q.toLowerCase().trim();\n        return POOL.filter(function(it){\n          if (ui.role && it.role !== ui.role) return false;\n          if (ui.favOnly && favs.indexOf(it.id) === -1) return false;\n          if (ui.tags.length){\n            for (var i = 0; i < ui.tags.length; i++){ if (it.tags.indexOf(ui.tags[i]) === -1) return false; }\n          }\n          if (q){\n            var hay = (it.name + ' ' + it.tags.join(' ') + ' ' + it.role).toLowerCase();\n            if (hay.indexOf(q) === -1) return false;\n          }\n          return true;\n        });\n      }\n\n      \/* Never an empty state: with no filters on, lead with a spread across the\n         roles rather than 380 rows of the same family. *\/\n      function recommended(rows){\n        if (ui.q || ui.role || ui.tags.length || ui.favOnly) return rows;\n        var seen = {}, head = [], tail = [];\n        rows.forEach(function(it){\n          seen[it.role] = (seen[it.role] || 0) + 1;\n          if (seen[it.role] <= 4) { head.push(it); } else { tail.push(it); }\n        });\n        return head.concat(tail);\n      }\n\n      function renderList(){\n        var rows = recommended(filtered());\n        countEl.textContent = rows.length\n          ? rows.length + ' piece' + (rows.length === 1 ? '' : 's') + (ui.q || ui.role || ui.tags.length || ui.favOnly ? ' match your filters' : ' \u2014 preview anything, it is always free')\n          : '';\n        list.innerHTML = '';\n        if (!rows.length){\n          var none = document.createElement('div');\n          none.className = 'ais-lib-none';\n          none.textContent = ui.favOnly ? 'No favourites yet \u2014 tap the heart on anything you like.' : 'Nothing matches. Try clearing a filter.';\n          list.appendChild(none);\n          moreBtn.style.display = 'none';\n          return;\n        }\n        rows.slice(0, ui.shown).forEach(function(it){ list.appendChild(libRow(it)); });\n        if (rows.length > ui.shown){\n          moreBtn.style.display = 'block';\n          moreBtn.textContent = 'Show ' + Math.min(LIB_PAGE, rows.length - ui.shown) + ' more (' + (rows.length - ui.shown) + ' left)';\n        } else {\n          moreBtn.style.display = 'none';\n        }\n      }\n\n      function usedSlot(it){\n        for (var i = 0; i < SLOTS.length; i++){\n          var k = SLOTS[i].key;\n          if (st.pick[k] && st.pick[k].id === it.id) return k;\n        }\n        return null;\n      }\n\n      function libRow(it){\n        var row = document.createElement('div');\n        var inSlot = usedSlot(it);\n        row.className = 'ais-lib-row' + (inSlot ? ' used' : '');\n\n        var play = document.createElement('button');\n        play.type = 'button'; play.className = 'ais-lib-play';\n        play.innerHTML = '&#9658;'; play.title = 'Preview'; play.setAttribute('aria-label', 'Preview ' + it.name);\n        play.addEventListener('click', function(e){ e.preventDefault(); e.stopPropagation(); libPreview(it, play); });\n\n        var body = document.createElement('div'); body.className = 'ais-lib-body';\n        var nm = document.createElement('div'); nm.className = 'ais-lib-name'; nm.textContent = it.name;\n        var sub = document.createElement('div'); sub.className = 'ais-lib-sub';\n        var dur = document.createElement('span'); dur.className = 'ais-lib-dur';\n        dur.textContent = libFmt(it.seconds) + (it.bpm ? ' \u00b7 ' + it.bpm + ' bpm' : '');\n        sub.appendChild(dur);\n        (it.tags || []).slice(0, 3).forEach(function(t){\n          var p = document.createElement('span'); p.className = 'ais-lib-pill'; p.textContent = t; sub.appendChild(p);\n        });\n        if (inSlot){\n          var used = document.createElement('span');\n          used.className = 'ais-lib-pill';\n          used.style.background = '#FCE7F3'; used.style.color = '#9D174D';\n          used.textContent = 'in ' + inSlot;\n          sub.appendChild(used);\n        }\n        body.appendChild(nm); body.appendChild(sub);\n\n        var favs = libFavs();\n        var fav = document.createElement('button');\n        fav.type = 'button';\n        fav.className = 'ais-lib-fav' + (favs.indexOf(it.id) !== -1 ? ' on' : '');\n        fav.innerHTML = favs.indexOf(it.id) !== -1 ? '&#9829;' : '&#9825;';\n        fav.title = 'Favourite'; fav.setAttribute('aria-label', 'Favourite ' + it.name);\n        fav.addEventListener('click', function(e){\n          e.preventDefault(); e.stopPropagation();\n          var on = libToggleFav(it.id);\n          fav.classList.toggle('on', on);\n          fav.innerHTML = on ? '&#9829;' : '&#9825;';\n          if (ui.favOnly) renderList();\n        });\n\n        var use = document.createElement('div'); use.className = 'ais-lib-use';\n        \/* The button follows the slot you are aiming at, not a guess based on\n           the piece's role. One verb, and it does what the strip above says. *\/\n        var def = ONLY || ui.aim;\n        if (!SLOTS.some(function(s){ return s.key === def; })) { def = SLOTS[0].key; }\n        var defSlot = SLOTS.filter(function(s){ return s.key === def; })[0] || SLOTS[0];\n        var main = document.createElement('button');\n        main.type = 'button';\n        main.textContent = inSlot\n          ? 'Remove'\n          : (SLOTS.length > 1 ? ('Use as ' + defSlot.label.toLowerCase()) : ('Use as ' + defSlot.label.toLowerCase()));\n        main.addEventListener('click', function(){\n          if (inSlot) { setSlot(inSlot, null); }\n          else {\n            setSlot(def, it);\n            \/\/ move the aim on, so opener -> bed -> closer flows without thinking\n            if (SLOTS.length > 1) { ui.aim = firstEmpty(); renderArrangement(); renderList(); }\n          }\n        });\n        var caret = document.createElement('button');\n        caret.type = 'button'; caret.innerHTML = '&#9662;'; caret.title = 'Use in another slot';\n        caret.setAttribute('aria-label', 'Choose a slot for ' + it.name);\n        caret.addEventListener('click', function(){\n          var existing = row.querySelector('.ais-lib-slots');\n          if (existing){ existing.remove(); row.classList.remove('open-slots'); return; }\n          var box = document.createElement('div'); box.className = 'ais-lib-slots';\n          SLOTS.forEach(function(slot){\n            var b = document.createElement('button');\n            b.type = 'button'; b.className = 'ais-chip ais-chip-tag';\n            b.innerHTML = slot.ico + ' ' + slot.label;\n            b.addEventListener('click', function(){ setSlot(slot.key, it); });\n            box.appendChild(b);\n          });\n          row.classList.add('open-slots');\n          row.appendChild(box);\n        });\n        use.appendChild(main);\n        if (SLOTS.length > 1) { use.appendChild(caret); }\n\n        row.appendChild(play); row.appendChild(body); row.appendChild(fav); row.appendChild(use);\n        return row;\n      }\n\n      \/* ---- wiring ---- *\/\n      var qTimer = null;\n      searchEl.addEventListener('input', function(){\n        clearTimeout(qTimer);\n        var v = searchEl.value;\n        qTimer = setTimeout(function(){ ui.q = v; ui.shown = LIB_PAGE; renderList(); }, 160);\n      });\n      favBtn.addEventListener('click', function(){\n        ui.favOnly = !ui.favOnly;\n        favBtn.classList.toggle('on', ui.favOnly);\n        favBtn.innerHTML = (ui.favOnly ? '&#9829;' : '&#9825;') + ' Favourites';\n        ui.shown = LIB_PAGE; renderList();\n      });\n      moreBtn.addEventListener('click', function(){ ui.shown += LIB_PAGE; renderList(); });\n\n      renderRoles();\n      renderTags();\n      commit();\n    });\n  }\n\n  \/* RJ24-EDIT (Aug 2026): every library piece is a plain public URL, so once a\n     result exists the customer can swap the opener, bed or closer and we can\n     re-render the whole mix in the browser - no API call, no credits, no wait.\n     This is the honest answer to \"how do I change the music without paying\n     again\": you just do, as often as you like. *\/\n  var LIB_BUF_CACHE = {};\n  function libBuffer(ctx, item){\n    if (!item) return Promise.resolve(null);\n    if (LIB_BUF_CACHE[item.id]) return Promise.resolve(LIB_BUF_CACHE[item.id]);\n    return fetchBuffer(ctx, item.audioUrl).then(function(b){\n      LIB_BUF_CACHE[item.id] = b;\n      return b;\n    }).catch(function(){ return null; });\n  }\n  function remixFromPicks(st){\n    if (!st.lastVoiceBuf) return Promise.resolve(null);\n    var ctx = new (window.AudioContext || window.webkitAudioContext)();\n    var p = st.pick || {};\n    return Promise.all([ libBuffer(ctx, p.open), libBuffer(ctx, p.bed), libBuffer(ctx, p.close) ])\n      .then(function(b){\n        st.lastOpenBuf = b[0]; st.lastStingBuf = b[1]; st.lastCloseBuf = b[2];\n        st.lastNoSfx = !(b[0] || b[2]);\n        return mixSfxTool(st.lastVoiceBuf, b[0], b[1], st.musicLevel, b[2], st.lastNoSfx);\n      });\n  }\n\n  \/* RJ24-EDIT (Aug 2026): one bar, the same on every result panel, saying the\n     thing that is easy to miss - the music is yours to change, for nothing, for\n     as long as you like. The button scrolls back to the picker rather than\n     making anyone hunt for it. *\/\n  function attachFreeMusicBar(o){\n    var host = el(o.resultId); if (!host) return;\n    var id = o.resultId + '-freemusic';\n    var existing = el(id);\n    if (existing) { existing.remove(); }   \/\/ rebuild: the music choice may have changed\n    var bar = document.createElement('div');\n    bar.id = id;\n    bar.style.cssText = 'margin-top:16px;padding:16px 18px;border:1.5px solid #A7F3D0;background:#F0FDF9;border-radius:14px;text-align:left;';\n    \/* RJ24-EDIT (Aug 2026): \"change it - free\" is only true for library beds.\n       If the customer PAID to have music composed, swapping to one of our beds\n       is still free, but composing again costs again. Say which one they are in\n       rather than making a promise that only half applies. *\/\n    var composed = !!(o.isComposed && o.isComposed());\n    var musicCost = (typeof CFG.musicCost === 'number') ? CFG.musicCost : 3;\n    bar.style.borderColor = composed ? '#FDE68A' : '#A7F3D0';\n    bar.style.background  = composed ? '#FFFBEB' : '#F0FDF9';\n    bar.innerHTML =\n      (composed\n        ? '<div style=\"font-weight:800;font-size:14px;color:#92400E;margin-bottom:4px;\">&#127925; Want different music?<\/div>' +\n          '<div style=\"font-size:13px;color:#475569;line-height:1.5;margin-bottom:12px;\">' +\n          'Switching to one of <b>our<\/b> beds is <b>free<\/b>, however many times you like &mdash; scroll up, ' +\n          'choose <b>Use our music<\/b>, pick one, then re-mix. Having music <b>composed again<\/b> is a fresh ' +\n          'piece of music, so that costs another <b>' + musicCost + ' credit' + (musicCost === 1 ? '' : 's') + '<\/b>.<\/div>'\n        : '<div style=\"font-weight:800;font-size:14px;color:#047857;margin-bottom:4px;\">&#127925; Don\\u2019t like the music? Change it \\u2014 free.<\/div>' +\n          '<div style=\"font-size:13px;color:#475569;line-height:1.5;margin-bottom:12px;\">' +\n          'Scroll up, pick something else from the library, then press re-mix. It never uses a credit, ' +\n          'however many times you do it \\u2014 the mixing happens right here in your browser.<\/div>') +\n      '<div style=\"display:flex;gap:8px;flex-wrap:wrap;\">' +\n      '<button type=\"button\" class=\"ais-dl-btn ais-dl-secondary\" id=\"' + id + '-go\" style=\"box-shadow:none;\">&#8593; Change the music<\/button>' +\n      '<button type=\"button\" class=\"ais-dl-btn\" id=\"' + id + '-mix\">&#8635; Re-mix (free)<\/button>' +\n      '<\/div>';\n    host.appendChild(bar);\n    el(id + '-go').addEventListener('click', function(){\n      var target = el(o.pickerId);\n      if (target) target.scrollIntoView({ behavior:'smooth', block:'center' });\n    });\n    el(id + '-mix').addEventListener('click', function(){\n      var btn = el(id + '-mix');\n      btn.disabled = true; btn.textContent = 'Re-mixing\\u2026';\n      Promise.resolve(o.remix()).then(function(url){\n        if (url){\n          var a = el(o.audioId); if (a) a.src = url;\n          var d = el(o.dlId); if (d) d.href = url;\n          toast('Re-mixed \\u2014 no credits used.');\n        }\n        btn.disabled = false; btn.innerHTML = '&#8635; Re-mix (free)';\n      }).catch(function(){\n        btn.disabled = false; btn.innerHTML = '&#8635; Re-mix (free)';\n        toast('Could not re-mix. Please try again.', 'error');\n      });\n    });\n  }\n\n  function refreshSfxHint(st, hint){\n    if (!hint) return;\n    var p = st.pick || {};\n    var bits = [];\n    if (p.open)  bits.push('<b>' + p.open.name + '<\/b> opens');\n    if (p.bed)   bits.push('<b>' + p.bed.name + '<\/b> plays under the voice');\n    if (p.close) bits.push('<b>' + p.close.name + '<\/b> closes');\n    if (!bits.length){\n      hint.innerHTML = 'Nothing chosen yet \u2014 <b>pick something below and it plays under your voice, free<\/b>. Leave it empty and you get the voice on its own.';\n      return;\n    }\n    var free = (CFG.freeProduction || CFG.sfxCost === 0);\n    hint.innerHTML = bits.join(' &middot; ') + (free ? ' &mdash; <b style=\"color:#059669;\">free, and you can change it as often as you like<\/b>.' : '.');\n  }\n\n  \/* ===== Reusable SFX tool factory (used by Sweeper AND DJ Drop) ===== *\/\n\n  \/* RJ24-EDIT (Nov 2026): sting genres for the music-under-the-voice in sweepers\/DJ drops.\n     Each one drives the music request: the AI gets `genre` + the `prompt` we send below.\n     'custom' shows a describe-box and the user-typed prompt is sent through. *\/\n  var STING_GENRES = [\n    { key:'softac',    ico:'\ud83c\udf05', label:'Soft AC',     prompt:'soft adult contemporary radio imaging music sting, mellow, warm, smooth, easy listening, gentle production, broadcast mastered, polished, full mix' },\n    { key:'mor',       ico:'\u2615', label:'MOR',         prompt:'middle of the road radio imaging music sting, easy listening, gentle, broad appeal, warm orchestration, mellow vocals, broadcast mastered, polished, full mix' },\n    { key:'ac',        ico:'\u2600\ufe0f', label:'AC',          prompt:'warm adult contemporary radio imaging music sting, polished, bright, mainstream, broadcast mastered, loud, punchy, full mix' },\n    { key:'country',   ico:'\ud83e\udd20', label:'Country',     prompt:'classic country radio imaging music sting, warm acoustic guitar, fiddle, mid tempo Nashville feel, broadcast mastered, polished, full mix' },\n    { key:'chr',       ico:'\ud83d\udd25', label:'CHR',         prompt:'tight punchy contemporary hit radio music sting, energetic, modern pop, glossy production, broadcast mastered, loud, full mix' },\n    { key:'oldies',    ico:'\ud83d\udcfb', label:'Oldies',      prompt:'classic oldies radio imaging music sting, warm nostalgic feel, retro production, sixties seventies vibe, broadcast mastered, loud, full mix' },\n    { key:'corporate', ico:'\ud83c\udfe2', label:'Corporate',   prompt:'clean corporate radio imaging music sting, professional, neutral, mainstream broadcast quality, mastered, loud, full mix' },\n    { key:'rock',      ico:'\ud83c\udfb8', label:'Rock',        prompt:'driving rock radio imaging music sting, electric guitar, energetic, big drums, classic rock feel, broadcast mastered, loud, full mix' },\n    { key:'custom',    ico:'\u2728', label:'Something else', prompt:'' }\n  ];\n\n  \/* v1.6 (May 2026): per-genre recommended BPM for the music sting. Sent to the\n     plugin which forwards it to ElevenLabs Music v2 as a tempo hint. Auto-picked\n     by genre so no UI needed; energetic genres get faster tempos, mellow ones\n     slower. The model isn't required to obey exactly but Music v2 is reasonably\n     responsive to tempo cues. Range: 60-180 BPM (musical). 0 = no hint. *\/\n  var STING_BPM = { softac: 80, mor: 85, ac: 100, country: 105, chr: 128, oldies: 115, corporate: 110, rock: 120, custom: 0 };\n\n  \/* v1.6.0 (Jun 2026): tempo pills offset the genre's auto-BPM so customers can\n     nudge faster or slower without typing a number. -15 \/ 0 \/ +15 maps to noticeable\n     but musical differences for radio imaging stings. Applied at submit time. *\/\n  var TEMPO_OFFSETS = { slower: -15, normal: 0, faster: 15 };\n\n  function buildSfxTool(p, opts){\n    \/\/ p = id prefix ('sw' or 'dj'). opts.noun = 'sweeper' \/ 'DJ drop'.\n    var st = { voice:null, sfxSelections:[], delivery:'lively', pace:'normal', sting:false, stingGenre:'chr', stingTempo:'normal', musicLevel:'medium', busy:false };\n    var curA=null, curB=null;\n    function pe(s){ return el(p+'-'+s); }\n    \/* RJ24-EDIT (Aug 2026, customer feedback): imaging voices are a SORT ORDER\n       now, not a filter. The old code REPLACED the whole list with the\n       imaging-flagged subset whenever there were six or more of them, so Sweeper\n       and DJ Drop showed a fraction of the library while every other tab showed\n       all of it. Three customers reported the same symptom - a voice heard on one\n       tab missing on another, and \"only 6 UK voices, all male\". Nothing is hidden\n       now: imaging reads sort to the top of each country group and keep their\n       badge, and the other 1,270-odd are reachable again. *\/\n    var PVOICES = VOICES.slice().sort(function(a, b){\n      var ai = a.is_imaging ? 0 : 1, bi = b.is_imaging ? 0 : 1;\n      if (ai !== bi) { return ai - bi; }\n      return String(a.name || '').localeCompare(String(b.name || ''));\n    });\n    var pCountries = sortCountries(uniq(PVOICES.map(function(v){ return v.country; })));\n    var pTypes = uniq(PVOICES.map(function(v){ return (v.type||'').split(' \u00b7 ')[0]; })).sort();\n\n    var toolKey = (p === 'sw') ? 'sweeper' : 'djdrop';\n    \/\/ Cost: voice word-count + 1 for the sound effect(s) (floor 2 if any picked,\n    \/\/ same price whether 1 or 2 pieces), + music if a sting is added. Matches\n    \/\/ the server's handle_sfx\/handle_tts logic.\n    function cost(){\n      if (freeFirstAvailable()) return 0;\n      var base;\n      if (st.mode==='record'){ base = Math.max(1, Math.ceil((st.recSeconds||8)\/(CFG.stsSecondsPerCredit||30))); }\n      else { var t=pe('script').value.trim(); var w=t?t.split(\/\\s+\/).filter(Boolean).length:0; base=Math.max(1,Math.ceil((w||1)\/WPC)); }\n      var hasSfx = (st.sfxSelections||[]).length > 0;\n      var floor = hasSfx ? (base + SFX_COST) : base;\n      \/\/ A library bed replaces generated music at no cost.\n      var bedChosen = !!(st.pick && st.pick.bed);\n      return floor + ((st.sting && !bedChosen) ? MUSIC_COST : 0);\n    }\n    function refreshCost(){\n      var isRec=(st.mode==='record');\n      var base, baseDetail;\n      if (isRec){ base=Math.max(1,Math.ceil((st.recSeconds||8)\/(CFG.stsSecondsPerCredit||30))); baseDetail=base+' for ~'+Math.round(st.recSeconds||0)+'s recording'; }\n      else { var t=pe('script').value.trim(); var w=t?t.split(\/\\s+\/).filter(Boolean).length:0; base=Math.max(1,Math.ceil((w||1)\/WPC)); baseDetail=base+' for ~'+w+' words'; }\n      var hasSfx = (st.sfxSelections||[]).length > 0;\n      var floor = hasSfx ? (base + SFX_COST) : base;\n      var bedChosen = !!(st.pick && st.pick.bed);\n      var total = floor + ((st.sting && !bedChosen) ? MUSIC_COST : 0);\n      var c=pe('cost');\n      if(c) {\n        if (freeFirstAvailable()){\n          \/* pass zza: say the budget here, where they are already looking at\n             the price. Silent until they are near it would be no better than\n             silent - the point is that it can never be a surprise. *\/\n          var _cap = freeWordCap();\n          var _w   = (st.mode === \"record\") ? 0 : countWords(p + \"-script\");\n          var _budget = \"\";\n          if (_cap && st.mode !== \"record\") {\n            \/* Say WHOSE limit it is every time it appears. A bare \"up to\n               10 words\" reads as a limit on the product; \"on the free one\"\n               reads as the shape of a gift, and is also simply true - a\n               paid creation has no word cap at all. *\/\n            _budget = (_w > _cap)\n              ? ' <span style=\"color:#DC2626;font-weight:600;\">\u2014 the free one is up to ' + _cap + ' words, trim ' + (_w - _cap) + '<\/span>'\n              : ' <span style=\"color:#94A3B8;\">\u2014 up to ' + _cap + ' words on the free one (you\\'re at ' + _w + ')<\/span>';\n          }\n          c.innerHTML = 'Cost: <b style=\"color:#059669;\">FREE<\/b>' + _budget + ' <span style=\"color:#94A3B8;\">(normally '+total+' credit'+(total===1?'':'s')+')<\/span>';\n        } else {\n          var sfxText = hasSfx ? (SFX_COST > 0 ? ' + ' + SFX_COST + ' for the sound effects' : ' \\u00b7 effects included free') : '';\n          c.innerHTML='Cost: <b>'+total+' credit'+(total===1?'':'s')+'<\/b> <span style=\"color:#94A3B8;\">('+baseDetail+sfxText+(st.sting?' + '+MUSIC_COST+' for music':'')+')<\/span>';\n        }\n        renderFreeFirstNotice(p+'-cost');\n      }\n      \/\/ Mirror into the bottom bar (parity with the advert tab).\n      if (typeof updateFooterText === 'function' && document.querySelector('#rj-ai-studio .ais-tab.active') && document.querySelector('#rj-ai-studio .ais-tab.active').getAttribute('data-tool')===toolKey){\n        updateFooterText({ tool:toolKey, cost: (freeFirstAvailable() ? 0 : total) });\n      }\n    }\n    \/\/ Expose this tool's footer refresh so switchTool can call it.\n    if (p === 'sw') { window.RJ24SwRefresh = function(){ updateFooterText({ tool:'sweeper', cost: cost() }); }; }\n    else if (p === 'dj') { window.RJ24DjRefresh = function(){ updateFooterText({ tool:'djdrop', cost: cost() }); }; }\n\n    \/\/ RJ24-EDIT (Jul 2026, SFX picker): browsable glitch\/separator picker\n    \/\/ replaces the old whoosh\/stab\/riser\/impact\/custom grid.\n    buildSfxPicker(p, st, refreshCost);\n\n    \/* RJ24-EDIT (Nov 2026): sting genre picker \u2014 only visible when the sting checkbox is on. *\/\n    var sgWrap = pe('sting-genre');\n    var sgPromptWrap = pe('sting-prompt-wrap');\n    if (sgWrap) {\n      STING_GENRES.forEach(function(g,i){\n        var d=document.createElement('div'); d.className='ais-genre'+(g.key==='chr'?' selected':''); d.setAttribute('data-sg', g.key);\n        d.innerHTML='<div class=\"ais-genre-ico\">'+g.ico+'<\/div><div class=\"ais-genre-name\">'+g.label+'<\/div>';\n        d.addEventListener('click', function(){\n          sgWrap.querySelectorAll('.ais-genre').forEach(function(x){x.classList.remove('selected');});\n          d.classList.add('selected');\n          st.stingGenre = g.key;\n          if (sgPromptWrap) sgPromptWrap.classList.toggle('show', g.key==='custom');\n        });\n        sgWrap.appendChild(d);\n      });\n      \/\/ Custom-prompt-box is HIDDEN by default until 'custom' picked.\n      if (sgPromptWrap) sgPromptWrap.classList.remove('show');\n    }\n\n    pe('script').addEventListener('input', function(){ var w=this.value.trim()?this.value.trim().split(\/\\s+\/).filter(Boolean).length:0; pe('count').textContent=w+' word'+(w===1?'':'s'); refreshCost(); });\n    function pills(suffix,attr,key){ var w=pe(suffix); if(!w) return; w.addEventListener('click',function(e){ var b=e.target.closest('button'); if(!b) return; w.querySelectorAll('button').forEach(function(x){x.classList.remove('active');}); b.classList.add('active'); st[key]=b.getAttribute(attr); }); }\n    pills('delivery','data-d','delivery'); pills('pace','data-p','pace');\n    \/* v1.6.0: wire the sting-tempo pills. Same pattern as delivery\/pace but\n       writes to st.stingTempo and uses a different data attribute. *\/\n    (function wireTempoPills(){\n      var w = pe('sting-tempo');\n      if (!w) return;\n      w.addEventListener('click', function(e){\n        var b = e.target.closest('button'); if (!b) return;\n        w.querySelectorAll('button').forEach(function(x){ x.classList.remove('active'); });\n        b.classList.add('active');\n        st.stingTempo = b.getAttribute('data-tempo') || 'normal';\n      });\n    })();\n    var stingCb=pe('sting'); if(stingCb){ stingCb.addEventListener('change',function(){\n      st.sting=this.checked;\n      refreshCost();\n      var lw=pe('musiclevel-wrap'); if(lw) lw.style.display=this.checked?'block':'none';\n      var gw=pe('sting-genre-wrap'); if(gw) gw.style.display=this.checked?'block':'none';\n    }); }\n    var mlW=pe('musiclevel'); if(mlW){ mlW.addEventListener('click',function(e){ var b=e.target.closest('button'); if(!b) return; mlW.querySelectorAll('button').forEach(function(x){x.classList.remove('active');}); b.classList.add('active'); st.musicLevel=b.getAttribute('data-m')||'medium'; }); }\n    refreshCost();\n\n    \/\/ voice picker\n    var vWrap=pe('voices');\n    (function(){ var ctrls=pe('voice-controls'); if(!ctrls) return;\n      var cOpts='<option value=\"\">All countries<\/option>'+pCountries.map(function(c){return '<option value=\"'+c+'\">'+c+'<\/option>';}).join('');\n      var tOpts='<option value=\"\">All types<\/option>'+pTypes.map(function(t){return '<option value=\"'+t+'\">'+t+'<\/option>';}).join('');\n      ctrls.innerHTML='<input type=\"text\" id=\"'+p+'-voice-search\" class=\"ais-input\" placeholder=\"Search voices\u2026\" style=\"flex:2;min-width:160px;\"><select id=\"'+p+'-voice-country\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+cOpts+'<\/select><select id=\"'+p+'-voice-type\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+tOpts+'<\/select>';\n    })();\n    function renderVoices(){\n      var q=(pe('voice-search')||{}).value||'', fc=(pe('voice-country')||{}).value||'', ft=(pe('voice-type')||{}).value||'';\n      q=q.toLowerCase().trim(); vWrap.innerHTML='';\n      if(!q&&!fc&&!ft){ vWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:14px;margin:0;padding:8px 0;\">Choose a country or type above (or search) to see voices. These are picked for punchy radio imaging.<\/p>'; return; }\n      var shown=PVOICES.filter(function(v){ if(fc&&v.country!==fc) return false; if(ft&&(v.type||'').split(' \u00b7 ')[0]!==ft) return false; if(q&&(v.name+' '+v.meta+' '+v.country).toLowerCase().indexOf(q)===-1) return false; return true; });\n      if(!shown.length){ vWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:13px;margin:0;\">No voices match.<\/p>'; return; }\n      var groups={}; shown.forEach(function(v){ var k=v.country||'Other'; (groups[k]=groups[k]||[]).push(v); });\n      sortCountries(Object.keys(groups)).forEach(function(country){ var h=document.createElement('div'); h.className='ais-voice-group'; h.textContent=country+' ('+groups[country].length+')'; vWrap.appendChild(h); groups[country].forEach(function(v){ vWrap.appendChild(card(v)); }); });\n    }\n    function card(v){\n      var d=document.createElement('div'); d.className='ais-voice'; d.setAttribute('data-v',v.id);\n      if(st.voice&&v.id===st.voice) d.classList.add('selected');\n      var badge = v.is_imaging ? ' <span class=\"ais-img-badge\">imaging<\/span>' : '';\n      d.innerHTML='<button type=\"button\" class=\"ais-voice-play\">&#9658;<\/button><span class=\"ais-voice-meta\"><b>'+v.name+'<\/b><small>'+(v.meta||v.type||'')+badge+'<\/small><\/span>';\n      d.addEventListener('click',function(e){ if(e.target.closest('.ais-voice-play')) return; vWrap.querySelectorAll('.ais-voice').forEach(function(x){x.classList.remove('selected');}); d.classList.add('selected'); st.voice=v.id; });\n      var pb=d.querySelector('.ais-voice-play');\n      pb.addEventListener('click',function(e){ e.preventDefault(); e.stopPropagation(); RJ24Preview.toggle(v, pb); });\n      return d;\n    }\n    [p+'-voice-search',p+'-voice-country',p+'-voice-type'].forEach(function(id){ var n=el(id); if(n){ n.addEventListener('input',renderVoices); n.addEventListener('change',renderVoices); } });\n    renderVoices();\n\n    function validate(){\n      if(st.mode==='record'){ if(!st.recBlob){ toast('Record or upload your '+opts.noun+' first.','error'); return false; } }\n      else if(!pe('script').value.trim()){ toast('Type your '+opts.noun+' line.','error'); return false; }\n      if(!st.voice){ toast('Choose a voice.','error'); return false; }\n      if(st.sting && st.stingGenre==='custom' && !(pe('sting-prompt')||{}).value){ toast('Describe the music sting you want.','error'); return false; }\n      return true;\n    }\n    function loading(on){ pe('result').classList.add('show'); pe('result-loading').style.display=on?'block':'none'; pe('result-ready').style.display=on?'none':'block'; pe('result').scrollIntoView({behavior:'smooth',block:'center'}); }\n    function fail(e){ loading(false); pe('result').classList.remove('show'); var b=pe('create'); b.disabled=false; b.textContent=(st.mode==='record'?'Create from my recording':'Create my '+opts.noun); st.busy=false; toast((e&&e.message)?e.message:'Could not create the '+opts.noun+'.','error'); }\n\n    pe('create').addEventListener('click', function(){\n      if(st.busy) return; if(!validate()) return;\n      \/* pass zza: check the free budget BEFORE sending them to register.\n         The server refuses an over-long free script, and until now that\n         refusal landed after the account had been created - so somebody\n         handed over an email and a password and got nothing back. Told at\n         this point instead, while they are still a stranger, it is a small\n         edit to their own script. *\/\n      if (!DEMO && st.mode !== \"record\" && freeFirstAvailable()) {\n        var _cap = freeWordCap();\n        var _w   = countWords(p + \"-script\");\n        if (_cap && _w > _cap) {\n          \/* Names the limit, says it belongs to the free one, and says the\n             way round it - which is buying credits. A refusal that does not\n             mention the paid option is just a dead end. *\/\n          toast(\"The free one is up to \" + _cap + \" words \u2014 this is \" + _w +\n                \". Trim \" + (_w - _cap) + \" and it is on us, or top up and make it any length.\", \"error\");\n          var _b = pe(\"script\"); if (_b && _b.focus) { _b.focus(); }\n          return;\n        }\n      }\n      if(!DEMO){ var c=cost(); if(!CFG.loggedIn){ toast('One step: an email and a password, and it makes it.',''); if(window.RJ24OpenLogin){ setTimeout(function(){window.RJ24OpenLogin('login');},600); } else if(CFG.accountUrl){ setTimeout(function(){window.location.href=CFG.accountUrl;},1200); } return; } if((CFG.credits||0)<c){ showCreditsShort(c); return; } }\n      st.busy=true; var b=pe('create'); b.disabled=true; b.textContent='Creating\u2026'; loading(true);\n      var isRec=(st.mode==='record');\n      var line=isRec?'':beatPause(cleanScript(pe('script').value.trim()));\n      var ctx=new (window.AudioContext||window.webkitAudioContext)();\n      var H={ 'Content-Type':'application\/json' }; if(CFG.nonce) H['X-WP-Nonce']=CFG.nonce;\n      var creditsAfter=null;\n      var wasFreeFirst=false;\n      var hasSfx = (st.sfxSelections||[]).length > 0;\n      \/\/ Typed -> \/tts; recorded -> \/speech-to-speech (same tool\/withMusic\/noSfx flags,\n      \/\/ so the backend charges music\/SFX identically). Downstream is unchanged.\n      var voiceJob = isRec\n        ? stsConvert(st.recBlob, st.voice, st.recSeconds, { tool:toolKey, withMusic:st.sting, noSfx:!hasSfx })\n        : fetch(CFG.ttsUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ text:line, voice:st.voice, delivery:st.delivery, pace:st.pace, tool:toolKey, pronounce:(pe('pronounce')||{}).value||'', withMusic:st.sting, noSfx:!hasSfx, punchy:true, voiceHints:{ noBreaths:true, finalCadenceDeclarative:true } })}).then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Voice failed'); return j; }); });\n      var jobs=[\n        voiceJob,\n        \/* RJ24-EDIT (Jul 2026, SFX picker): ONE \/sfx call carrying up to 2\n           customer-picked pieces, instead of separate opening\/closing\/mid\n           AI-generation calls. Returns { pieces: [...] } \u2014 pieces[0] is the\n           opening piece, pieces[1] (if present) is the closing piece. *\/\n        (!hasSfx)\n          ? Promise.resolve(null)\n          : fetch(CFG.sfxUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ selections: JSON.stringify(st.sfxSelections) })}).then(function(r){ return r.json().then(function(j){ return r.ok?j:null; }); }).catch(function(){ return null; })\n      ];\n      if(st.sting && !(st.pick && st.pick.bed)){\n        \/* RJ24-EDIT (Nov 2026): user-driven sting genre. The chosen genre maps to a\n           tuned prompt baked above; 'custom' uses the user's describe text instead. *\/\n        var sgDef = null;\n        for (var sgi=0; sgi<STING_GENRES.length; sgi++){ if (STING_GENRES[sgi].key === st.stingGenre){ sgDef = STING_GENRES[sgi]; break; } }\n        var sgKey = sgDef ? sgDef.key : 'chr';\n        var sgPrompt = (sgKey === 'custom')\n          ? ((pe('sting-prompt')||{}).value||'').trim()\n          : (sgDef ? sgDef.prompt : STING_GENRES[1].prompt);\n        \/* v1.6: auto-pick BPM by genre. Plugin v1.3.0+ uses it; older plugins ignore the param.\n           v1.6.0: also apply the tempo pill offset so the customer can nudge faster\/slower\n           without typing a number. Clamp to musical range 60-180. *\/\n        var sgBpm = (typeof STING_BPM[sgKey] === 'number') ? STING_BPM[sgKey] : 0;\n        var tempoOffset = (typeof TEMPO_OFFSETS[st.stingTempo] === 'number') ? TEMPO_OFFSETS[st.stingTempo] : 0;\n        if (sgBpm > 0 && tempoOffset !== 0) {\n          sgBpm = Math.max(60, Math.min(180, sgBpm + tempoOffset));\n        }\n        jobs.push(startMusic(JSON.stringify({ genre:sgKey, prompt:sgPrompt, seconds:8, bpm:sgBpm, partOfTts:true }), H));\n      } else {\n        jobs.push(Promise.resolve(null));\n      }\n      Promise.all(jobs).then(function(res){\n        if(res[0]&&typeof res[0].credits!=='undefined') creditsAfter=res[0].credits;\n        if(res[0] && res[0].freeFirst){ wasFreeFirst = true; CFG.freeFirstAvailable = false; }\n        if(res[0] && res[0].sessionToken){ st.sessionToken = res[0].sessionToken; } \/\/ RJ24-EDIT (Jul 2026): takes picker\n        var vU=res[0]&&res[0].audioUrl, mU=res[2];\n        var pieces = (res[1] && res[1].pieces) || [];\n        \/* RJ24-EDIT (Aug 2026): map the returned pieces back to their slot using\n           the index the picker recorded, so slot order can never drift. Falls back\n           to the old positional read when pickMap is absent. *\/\n        var pm = st.pickMap || {};\n        var openU  = (typeof pm.open  === 'number' && pieces[pm.open])  ? pieces[pm.open].audioUrl  : (pieces[0] && pieces[0].audioUrl);\n        var closeU = (typeof pm.close === 'number' && pieces[pm.close]) ? pieces[pm.close].audioUrl : (pieces[1] && pieces[1].audioUrl);\n        var bedU   = (typeof pm.bed   === 'number' && pieces[pm.bed])   ? pieces[pm.bed].audioUrl   : null;\n        \/\/ A library bed costs nothing and beats generated music to the slot.\n        if (bedU) { mU = bedU; }\n        \/* RJ24-EDIT (Nov 2026): persist last-generated dry voice URL + script\n           + chosen music vibe so the \"Get RJ24 to produce it\" button can post\n           them to \/produce. The voice URL we keep is the raw TTS output that\n           the server saved under \/wp-content\/uploads\/rj24-ai-studio\/ \u2014 that's\n           the file the \/produce endpoint validates and attaches to the order. *\/\n        st.lastUrl = vU || null;\n        st.lastScript = isRec ? '[Recorded \u2014 Say It Yourself]' : pe('script').value.trim();\n        st.lastGenre = st.sting ? (st.stingGenre || '') : '';\n        return Promise.all([ vU?fetchBuffer(ctx,vU):null, openU?fetchBuffer(ctx,openU):null, mU?fetchBuffer(ctx,mU):null, closeU?fetchBuffer(ctx,closeU):null ]);\n      })\n        .then(function(bufs){\n          \/\/ RJ24-EDIT (Jul 2026, multi-take): keep the non-voice buffers so a\n          \/\/ retake can rebuild the full mix around just a new voice take.\n          st.lastVoiceBuf = bufs[0];   \/\/ kept so pieces can be swapped free afterwards\n          st.lastOpenBuf = bufs[1]; st.lastStingBuf = bufs[2]; st.lastCloseBuf = bufs[3]; st.lastNoSfx = !hasSfx;\n          return mixSfxTool(bufs[0], bufs[1], bufs[2], st.musicLevel, bufs[3], !hasSfx).then(function(url){ return { url: url, voiceBuf: bufs[0] }; });\n        })\n        .then(function(out){\n          var url = out.url;\n          pe('audio').src=url;\n          var dl=pe('dl'); if(dl) dl.href=url;\n          loading(false); b.disabled=false; b.textContent='Create again'; st.busy=false;\n          attachMixEditorButton({\n            resultId: p + '-result-ready', audioId: p + '-audio', dlId: p + '-dl',\n            parts: function(){\n              \/* Straight off the same plan mixSfxTool renders from, so the\n                 timeline is a picture of the actual audio - overlaps included.\n                 An opener with a fade tail is MEANT to have the voice arrive over\n                 it, and the closer is meant to land under the last words. *\/\n              var pk = st.pick || {};\n              var P = sfxPlan(st.lastVoiceBuf, st.lastOpenBuf, st.lastStingBuf,\n                              st.musicLevel, st.lastCloseBuf, st.lastNoSfx);\n              var out = [];\n              if (st.lastOpenBuf) out.push({ id:'open', name:(pk.open&&pk.open.name)||'Opener',\n                buf:st.lastOpenBuf, start:0, trimA:0, trimB:st.lastOpenBuf.duration,\n                env:[{t:0,g:1},{t:st.lastOpenBuf.duration,g:1}] });\n              if (st.lastStingBuf && P.midAt !== null){\n                out.push({ id:'mid', name:(pk.bed&&pk.bed.name)||'Middle', buf:st.lastStingBuf,\n                  start:P.midAt, trimA:0, trimB:Math.max(0.3, P.midPlay),\n                  env:envClip(envRel(P.midEnv, P.midAt), Math.max(0.3, P.midPlay)) });\n              }\n              if (st.lastVoiceBuf) out.push({ id:'voice', name:'Your voice', buf:st.lastVoiceBuf,\n                start:P.vStart, trimA:0, trimB:st.lastVoiceBuf.duration,\n                env:[{t:0,g:1},{t:st.lastVoiceBuf.duration,g:1}] });\n              if (st.lastCloseBuf && P.secondAt !== null) out.push({ id:'close',\n                name:(pk.close&&pk.close.name)||'Closer', buf:st.lastCloseBuf,\n                start:P.secondAt, trimA:0, trimB:Math.max(0.3, P.closePlay),\n                env:[{t:0,g:1},{t:Math.max(0.3, P.closePlay),g:1}] });\n              return out;\n            }\n          });\n          \/* Free-swap bar: change a piece above, press this, hear it. No credits. *\/\n          (function(){\n            var host = pe('result-ready'); if (!host) return;\n            var barId = p + '-freeswap';\n            if (!el(barId)) {\n              var bar = document.createElement('div');\n              bar.id = barId;\n              bar.style.cssText = 'margin-top:14px;padding:12px 14px;border:1px solid #FBCFE8;background:#FDF2F8;border-radius:12px;text-align:left;';\n              bar.innerHTML = '<div style=\"font-weight:800;font-size:13px;color:#9D174D;margin-bottom:8px;\">&#127911; Not quite right? Change the opener, bed or closer above and re-mix &mdash; free, as often as you like.<\/div>' +\n                '<button type=\"button\" class=\"ais-dl-btn ais-dl-secondary\" id=\"' + barId + '-btn\" style=\"box-shadow:none;\">&#8635; Re-mix (free)<\/button>';\n              host.appendChild(bar);\n              el(barId + '-btn').addEventListener('click', function(){\n                var btn2 = el(barId + '-btn');\n                btn2.disabled = true; btn2.textContent = 'Re-mixing\u2026';\n                remixFromPicks(st).then(function(url2){\n                  if (url2) {\n                    pe('audio').src = url2;\n                    var dl2 = pe('dl'); if (dl2) dl2.href = url2;\n                    toast('Re-mixed \u2014 no credits used.');\n                  }\n                  btn2.disabled = false; btn2.innerHTML = '&#8635; Re-mix (free)';\n                }).catch(function(){\n                  btn2.disabled = false; btn2.innerHTML = '&#8635; Re-mix (free)';\n                  toast('Could not re-mix. Please try again.', 'error');\n                });\n              });\n            }\n          })();\n          if (wasFreeFirst) showFreeFirstBadge(p+'-result-ready');\n          if(creditsAfter!==null){ CFG.credits=creditsAfter; updateFooterText({}); }\n          refreshCost();\n          \/\/ RJ24-EDIT (Jul 2026, multi-take): seed the takes picker (typed only \u2014\n          \/\/ a fixed recording has no re-performable take).\n          if (st.mode==='record'){\n            if (st.takesCtrl) st.takesCtrl.reset();\n          } else {\n            if (!st.takesCtrl) {\n              st.takesCtrl = createTakesController({\n                key: p,\n                hostEl: pe('result-ready'),\n                audioEl: pe('audio'),\n                dlEl: pe('dl'),\n                getSessionToken: function(){ return st.sessionToken; },\n                remix: function(voiceBuf){\n                  return mixSfxTool(voiceBuf, st.lastOpenBuf, st.lastStingBuf, st.musicLevel, st.lastCloseBuf, st.lastNoSfx);\n                },\n                onSelect: function(url2, voiceBuf, rawUrl){ st.lastUrl = rawUrl; }\n              });\n            }\n            st.takesCtrl.seed(url, out.voiceBuf, st.lastUrl);\n          }\n          \/* v1.6.1: save server-side *\/\n          if (window.RJ24SaveCreation && CFG.loggedIn) {\n            var voiceObj = (PVOICES.find ? PVOICES.find(function(v){return v.id===st.voice;}) : null) || {};\n            var sfxNames = (st.sfxSelections||[]).map(function(s){ return s.style+'\/'+s.file; }).join(', ');\n            var saveMeta = {\n              tool: toolKey,\n              script: isRec ? '[Recorded \u2014 Say It Yourself]' : pe('script').value.trim(),\n              voice_name: voiceObj.name || '',\n              voice_country: voiceObj.country || '',\n              sfx: sfxNames,\n              sting: !!st.sting,\n              sting_genre: st.sting ? (st.stingGenre || '') : '',\n              sting_tempo: st.sting ? (st.stingTempo || 'normal') : '',\n              duration: 0\n            };\n            var pillId = p + '-save-pill';\n            if (!el(pillId)) {\n              var pill = document.createElement('div');\n              pill.id = pillId;\n              pill.className = 'ais-save-pill';\n              pill.style.display = 'none';\n              var resReady = pe('result-ready');\n              if (resReady) resReady.appendChild(pill);\n            }\n            setTimeout(function(){\n              saveMeta.duration = (pe('audio') && pe('audio').duration) || 0;\n              window.RJ24SaveCreation(url, saveMeta, pillId);\n            }, 800);\n          }\n        })\n        .catch(function(e){ fail(e); });\n    });\n\n    \/* RJ24-EDIT (Nov 2026): wire the \"Get RJ24 to produce it\" upsell button.\n       Same \/produce endpoint as the advert tab \u2014 just sends a `tool` key so the\n       order item name and admin email reflect what was generated. The voice URL\n       must point to a file we generated (the endpoint enforces this), so this\n       only fires after a successful create. *\/\n    var prodBtn = pe('produce-btn');\n    if (prodBtn){\n      prodBtn.addEventListener('click', function(){\n        if (typeof DEMO !== 'undefined' && DEMO){ toast('Production orders work once AI Studio is live (out of demo mode).'); return; }\n        if (!CFG.loggedIn){ toast('Please log in first.','error'); return; }\n        if (!st.lastUrl){ toast('Create your ' + (opts.noun || 'piece') + ' first.','error'); return; }\n        prodBtn.disabled = true;\n        var origLabel = prodBtn.textContent;\n        prodBtn.textContent = 'Creating order\u2026';\n        var H = { 'Content-Type':'application\/json' }; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n        fetch(CFG.produceUrl, {\n          method:'POST', credentials:'same-origin', headers:H,\n          body: JSON.stringify({\n            tool: toolKey,\n            voiceUrl: st.lastUrl,\n            script: st.lastScript || '',\n            genre: st.lastGenre || ''\n          })\n        }).then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Could not create order'); return j; }); })\n          .then(function(j){\n            toast('Order created! Taking you to checkout\u2026');\n            if (j.payUrl) setTimeout(function(){ window.location.href = j.payUrl; }, 1200);\n          })\n          .catch(function(e){ toast(e.message || 'Could not create the order.','error'); prodBtn.disabled = false; prodBtn.textContent = origLabel; });\n      });\n    }\n    \/\/ Reflect configurable fee on the upsell box\n    var pfee = pe('produce-fee');\n    if (pfee && CFG.produceFee) pfee.textContent = CFG.produceFee;\n\n    \/\/ RJ24-EDIT (Jul 2026): Type \/ Record toggle for this SFX tool.\n    wireModeToggle({\n      toggleId:p+'-modetoggle', barId:p+'-modebar', typeStepId:p+'-type-step', recordStepId:p+'-record-step',\n      voicenoteId:p+'-record-voicenote', createBtnId:p+'-create',\n      typeLabel:'Create my '+opts.noun, recordLabel:'Create from my recording',\n      cap: (toolKey==='sweeper'?RECORD_CAPS.sweeper:RECORD_CAPS.djdrop), state: st,\n      onReady:function(){ refreshCost(); }, onModeChange:function(){ refreshCost(); }\n    });\n\n    return { refreshCost:refreshCost, state:st };\n  }\n\n  \/\/ SFX-tool mix: opening piece leads (if selected), voice punches in, sting ducks\n  \/\/ UNDER the voice, then sting rises and the closing piece (if selected) hits as\n  \/\/ it fades out. RJ24-EDIT (Jul 2026, SFX picker): rebuilt around the customer's\n  \/\/ 1-2 specific library selections instead of AI-generated whoosh\/stab\/riser\/\n  \/\/ impact hits \u2014 openBuf\/closeBuf are now the customer's actual chosen pieces\n  \/\/ (closeBuf falls back to openBuf if only one was picked, so a single\n  \/\/ selection still gets used at both ends). The v1.5\/v1.6 synthesised\n  \/\/ sub-drop\/impact layers and DSP chain are kept \u2014 they still add useful\n  \/\/ weight\/polish under whatever real pieces the customer picked.\n  function mixSfxTool(voiceBuf, openBuf, stingBuf, level, closeBufIn, noSfx){\n    voiceBuf = rj24NormaliseVoice(voiceBuf);\n    \/\/ RJ24-EDIT (Jul 2026): a single selected piece plays ONCE (at the open) \u2014\n    \/\/ no more reusing it at the close. A SECOND selected piece is placed at the\n    \/\/ most natural pause in the read (a real sentence gap), like a proper\n    \/\/ sweeper\/DJ drop; if the read has no clear pause we fall back to closing on\n    \/\/ it at the end. openBuf is the first (e.g. glitch) piece; closeBufIn is the\n    \/\/ second, or undefined when only one was picked.\n    var sr = 44100;\n    var P = sfxPlan(voiceBuf, openBuf, stingBuf, level, closeBufIn, noSfx);\n    var vDur = P.vDur, lv = P.lv, openDur = P.openDur, lead = P.lead;\n    var vStart = P.vStart, vEnd = P.vEnd;\n    var secondBuf = P.secondBuf, secondDur = P.secondDur, secondAt = P.secondAt;\n    var secondAtClose = P.secondAtClose, closePlay = P.closePlay, total = P.total;\n\n    var ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(\n      2, Math.ceil(sr * total), sr\n    );\n\n    \/\/ ===== DSP HELPERS =====\n\n    \/\/ Saturation curve (tanh)\n    function makeSaturationCurve(drive){\n      var n = 4096, c = new Float32Array(n);\n      for (var i = 0; i < n; i++){\n        var x = (i * 2 \/ n) - 1;\n        c[i] = Math.tanh(x * drive) \/ Math.tanh(drive);\n      }\n      return c;\n    }\n\n    \/\/ Sub-drop layer: sine sweep 80Hz -> 30Hz with envelope\n    function makeSubDrop(durationS){\n      var len = Math.floor(sr * durationS);\n      var buf = ctx.createBuffer(2, len, sr);\n      for (var ch = 0; ch < 2; ch++){\n        var data = buf.getChannelData(ch);\n        var phase = 0;\n        for (var i = 0; i < len; i++){\n          var t = i \/ sr;\n          var progress = t \/ durationS;\n          var freq = 80 * Math.pow(0.375, progress); \/\/ 80 -> 30 Hz\n          phase += 2 * Math.PI * freq \/ sr;\n          var attackS = 0.005;\n          var env = (t < attackS)\n            ? (t \/ attackS)\n            : Math.pow(Math.max(0, 1 - (t - attackS) \/ (durationS - attackS)), 1.5);\n          data[i] = Math.sin(phase) * env * 0.7;\n        }\n      }\n      return buf;\n    }\n\n    \/\/ Impact layer: filtered noise burst, fast attack + fast decay\n    function makeImpact(durationS){\n      var len = Math.floor(sr * durationS);\n      var buf = ctx.createBuffer(2, len, sr);\n      for (var ch = 0; ch < 2; ch++){\n        var data = buf.getChannelData(ch);\n        var lp = 0;\n        var lpCoef = 0.15; \/\/ lowpass filter coefficient\n        for (var i = 0; i < len; i++){\n          var t = i \/ sr;\n          var noise = (Math.random() * 2 - 1);\n          lp = lp + lpCoef * (noise - lp);\n          var env = Math.exp(-t * 30) * (t < 0.005 ? t \/ 0.005 : 1);\n          data[i] = lp * env * 0.9;\n        }\n      }\n      return buf;\n    }\n\n    \/\/ Compute voice envelope in N-ms windows (RMS per window)\n    function computeEnvelope(buf, windowMs){\n      if (!buf) return null;\n      var ch = buf.getChannelData(0);\n      var samplesPerWindow = Math.max(1, Math.floor(buf.sampleRate * windowMs \/ 1000));\n      var out = [];\n      for (var i = 0; i < ch.length; i += samplesPerWindow){\n        var end = Math.min(i + samplesPerWindow, ch.length);\n        var sum = 0;\n        for (var j = i; j < end; j++) sum += ch[j] * ch[j];\n        out.push(Math.sqrt(sum \/ Math.max(1, end - i)));\n      }\n      return out;\n    }\n\n    \/\/ Smooth envelope (3-point moving average)\n    function smoothEnvelope(env){\n      if (!env || env.length < 3) return env;\n      var out = new Array(env.length);\n      for (var i = 0; i < env.length; i++){\n        var p = env[Math.max(0, i-1)];\n        var c = env[i];\n        var n = env[Math.min(env.length-1, i+1)];\n        out[i] = (p + c + n) \/ 3;\n      }\n      return out;\n    }\n\n    \/\/ Mid-side stereo widener\n    function makeWidener(c, widenAmount){\n      var w = widenAmount;\n      var splitter = c.createChannelSplitter(2);\n      var merger   = c.createChannelMerger(2);\n      var lToL = c.createGain(); lToL.gain.value = (1 + w) \/ 2;\n      var rToL = c.createGain(); rToL.gain.value = (1 - w) \/ 2;\n      var lToR = c.createGain(); lToR.gain.value = (1 - w) \/ 2;\n      var rToR = c.createGain(); rToR.gain.value = (1 + w) \/ 2;\n      splitter.connect(lToL, 0); splitter.connect(rToL, 1);\n      splitter.connect(lToR, 0); splitter.connect(rToR, 1);\n      lToL.connect(merger, 0, 0); rToL.connect(merger, 0, 0);\n      lToR.connect(merger, 0, 1); rToR.connect(merger, 0, 1);\n      return { input: splitter, output: merger };\n    }\n\n    \/\/ RJ24-EDIT (Jul 2026): find the most natural pause in the read \u2014 the longest\n    \/\/ internal silence gap between phrases \u2014 so a 2nd SFX piece can punctuate it\n    \/\/ like a real sweeper\/DJ drop. Returns seconds INTO the voice, or null if the\n    \/\/ read is one continuous phrase. Leading\/trailing silence is ignored; short\n    \/\/ inter-word gaps are filtered out by a minimum-pause length.\n    \/* hand it up to the plans, which live in the outer scope *\/\n    _findVoicePause = function(b){ return findVoicePause(b); };\n\n    function findVoicePause(buf){\n      if (!buf) return null;\n      var winMs = 20;\n      var env = smoothEnvelope(computeEnvelope(buf, winMs));\n      if (!env || env.length < 6) return null;\n      var peak = 0.0001;\n      for (var i = 0; i < env.length; i++){ if (env[i] > peak) peak = env[i]; }\n      var silfloor = Math.max(0.012, peak * 0.06);        \/\/ below this = \"silent\" window\n      \/\/ Where does speech actually start\/end? (ignore head\/tail silence)\n      var spStart = -1, spEnd = -1;\n      for (var a = 0; a < env.length; a++){ if (env[a] >= silfloor){ spStart = a; break; } }\n      for (var b = env.length - 1; b >= 0; b--){ if (env[b] >= silfloor){ spEnd = b; break; } }\n      if (spStart < 0 || spEnd <= spStart) return null;\n      var minWins = Math.max(2, Math.round(170 \/ winMs)); \/\/ >=170ms = a real pause, not a word gap\n      var best = null, runStart = -1;\n      for (var k = spStart + 1; k <= spEnd; k++){\n        var silent = env[k] < silfloor;\n        if (silent && runStart < 0) runStart = k;\n        if ((!silent || k === spEnd) && runStart >= 0){\n          var runEnd = silent ? k : (k - 1);\n          var len = runEnd - runStart + 1;\n          \/\/ fully internal (voice both before AND after) and long enough\n          if (len >= minWins && runStart > spStart && runEnd < spEnd){\n            if (!best || len > best.len) best = { start: runStart, len: len };\n          }\n          runStart = -1;\n        }\n      }\n      if (!best) return null;\n      return (best.start * winMs) \/ 1000;                 \/\/ start of the gap, in seconds\n    }\n\n    \/\/ ===== MASTER CHAIN: gain -> SMILE EQ -> saturation -> limiter -> destination =====\n    \/\/ v1.6.9: 0.92 -> 0.85 for more headroom before the saturator + limiter, so\n    \/\/ the stacked opening\/closing hits don't slam the chain into distortion.\n    var master = ctx.createGain(); master.gain.value = 0.85;\n\n    \/\/ v1.5: Smile EQ \u2014 the broadcast frequency curve\n    var eqLow = ctx.createBiquadFilter();\n    eqLow.type = 'lowshelf';\n    eqLow.frequency.value = 90;\n    eqLow.gain.value = 2.0;\n\n    var eqMidCut = ctx.createBiquadFilter();\n    eqMidCut.type = 'peaking';\n    eqMidCut.frequency.value = 350;\n    eqMidCut.Q.value = 1.0;\n    eqMidCut.gain.value = -1.5;\n\n    var eqPresence = ctx.createBiquadFilter();\n    eqPresence.type = 'peaking';\n    eqPresence.frequency.value = 4000;\n    eqPresence.Q.value = 0.8;\n    eqPresence.gain.value = 2.0;\n\n    var eqAir = ctx.createBiquadFilter();\n    eqAir.type = 'highshelf';\n    eqAir.frequency.value = 10000;\n    \/\/ v1.6.9: +3dB -> +1.5dB. The big air boost feeding the saturator was adding\n    \/\/ harsh, fizzy top end that reads as distortion on bright sweepers.\n    eqAir.gain.value = 1.5;\n\n    var sat = ctx.createWaveShaper();\n    \/\/ v1.6.9: drive cut 1.5 -> 1.05. At 1.5, with the stacked SFX\/synth layers,\n    \/\/ the master saturator was audibly crunching short, dense sweepers\/drops\n    \/\/ (the \"distorted\" feedback). 1.05 keeps a hint of glue without clipping.\n    sat.curve = makeSaturationCurve(1.05);\n    sat.oversample = '4x';\n\n    var limiter = ctx.createDynamicsCompressor();\n    limiter.threshold.value = -3.0;\n    limiter.knee.value      = 0.0;\n    limiter.ratio.value     = 20.0;\n    limiter.attack.value    = 0.002;\n    limiter.release.value   = 0.18;\n\n    master\n      .connect(eqLow).connect(eqMidCut).connect(eqPresence).connect(eqAir)\n      .connect(sat).connect(limiter).connect(ctx.destination);\n\n    \/\/ ===== MUSIC BUS: compressor + v1.5 widener =====\n    var musicBus = ctx.createGain(); musicBus.gain.value = 1.0;\n    var musicComp = ctx.createDynamicsCompressor();\n    musicComp.threshold.value = -15.0;\n    musicComp.knee.value      = 6.0;\n    musicComp.ratio.value     = 3.0;\n    musicComp.attack.value    = 0.010;\n    musicComp.release.value   = 0.25;\n    var musicWidener = makeWidener(ctx, 1.35);\n    musicBus.connect(musicComp);\n    musicComp.connect(musicWidener.input);\n    musicWidener.output.connect(master);\n\n    \/\/ ---- Music sting with v1.5 SIDECHAIN DUCKING ----\n    if (stingBuf && P.midAt !== null) {\n      \/* The middle element: a piece dropped into a pause in the read. Straight\n         playback at full level with a short tail if we have to cut it - no\n         ducking and no looping, because there is nothing playing underneath. *\/\n      var stS = ctx.createBufferSource();\n      stS.buffer = stingBuf;\n      var stG = ctx.createGain();\n      envApply(stG, P.midEnv);\n      stS.connect(stG).connect(musicBus);\n\n      \/\/ Silence-skip on AI-generated pieces\n      var musicOffset = 0;\n      try {\n        var ch = stingBuf.getChannelData(0);\n        var sr2 = stingBuf.sampleRate;\n        var maxScan = Math.min(ch.length, Math.floor(sr2 * 3));\n        for (var ms = 0; ms < maxScan; ms++){\n          if (Math.abs(ch[ms]) > 0.03){ musicOffset = Math.max(0, (ms \/ sr2) - 0.02); break; }\n        }\n      } catch(e){}\n      stS.start(P.midAt, musicOffset,\n                Math.max(0.05, Math.min(P.midPlay, stingBuf.duration - musicOffset)));\n    }\n\n    \/\/ ---- Opening piece (customer's chosen library SFX) with v1.5 TRANSIENT DESIGNER ----\n    if (openBuf) {\n      var sS = ctx.createBufferSource();\n      sS.buffer = openBuf;\n      \/\/ Transient designer: fast-attack, low-threshold comp snaps the front\n      var sTD = ctx.createDynamicsCompressor();\n      sTD.threshold.value = -10.0;\n      sTD.knee.value      = 0.0;\n      sTD.ratio.value     = 6.0;\n      sTD.attack.value    = 0.001;\n      sTD.release.value   = 0.05;\n      var sG = ctx.createGain();\n      sG.gain.setValueAtTime(0.95, 0);\n      sG.gain.linearRampToValueAtTime(0.95, Math.max(0.05, vStart - 0.05));\n      sG.gain.linearRampToValueAtTime(0.30, vStart + 0.10);\n      sG.gain.linearRampToValueAtTime(0.18, vStart + 0.45);\n      sG.gain.setValueAtTime(0.18, Math.max(vStart + 0.45, vEnd - 0.05));\n      sG.gain.linearRampToValueAtTime(0.0, vEnd + 0.05);\n      sS.connect(sTD).connect(sG).connect(master);\n      sS.start(0);\n    }\n\n    \/\/ ---- v1.5 OPENING LAYERS: synthesised sub-drop + impact (FREE, no API) ----\n    \/\/ v1.6.1: skip when the user picked no SFX pieces \u2014 synth layers were\n    \/\/ designed to ADD weight to a real hit; on their own they sound like an\n    \/\/ unwanted, unrequested hit.\n    if (!noSfx) {\n      var openingHit = Math.max(0, vStart - 0.08);\n\n      var subBuf = makeSubDrop(0.50);\n      var subS = ctx.createBufferSource(); subS.buffer = subBuf;\n      var subG = ctx.createGain(); subG.gain.value = 0.45;\n      subS.connect(subG).connect(master);  \/\/ direct to master (don't widen bass)\n      subS.start(openingHit);\n\n      var impBuf = makeImpact(0.20);\n      var impS = ctx.createBufferSource(); impS.buffer = impBuf;\n      var impG = ctx.createGain(); impG.gain.value = 0.40;\n      impS.connect(impG).connect(master);\n      impS.start(openingHit);\n    }\n\n    \/\/ ---- Voice chain: HPF -> body EQ -> presence EQ -> v1.5 DE-ESSER ->\n    \/\/      [dry comp + parallel comp + reverb send] -> master ----\n    if (voiceBuf) {\n      var vS = ctx.createBufferSource(); vS.buffer = voiceBuf;\n      var hpf  = ctx.createBiquadFilter();  hpf.type = 'highpass';  hpf.frequency.value = 90;\n      var body = ctx.createBiquadFilter();  body.type = 'peaking'; body.frequency.value = 220;  body.Q.value = 0.7; body.gain.value = 2.0;\n      var pres = ctx.createBiquadFilter();  pres.type = 'peaking'; pres.frequency.value = 4200; pres.Q.value = 0.8; pres.gain.value = 3.0;\n\n      \/\/ v1.5 DE-ESSER: split-band approach\n      \/\/ Low band: lowpass at 5500Hz (full level, untouched)\n      \/\/ High band: highpass at 5500Hz -> fast comp (sibilants get squashed) -> mix back\n      var deessLow = ctx.createBiquadFilter();\n      deessLow.type = 'lowpass';\n      deessLow.frequency.value = 5500;\n\n      var deessHigh = ctx.createBiquadFilter();\n      deessHigh.type = 'highpass';\n      deessHigh.frequency.value = 5500;\n\n      var deessComp = ctx.createDynamicsCompressor();\n      deessComp.threshold.value = -28.0;\n      deessComp.knee.value      = 4.0;\n      deessComp.ratio.value     = 6.0;\n      deessComp.attack.value    = 0.0008;\n      deessComp.release.value   = 0.05;\n\n      var deessOut = ctx.createGain();\n      deessOut.gain.value = 0.75; \/\/ pull back compressed sibilants slightly\n\n      var voiceMix = ctx.createGain();\n      voiceMix.gain.value = 1.0;\n\n      \/\/ Main (dry-ish) compressor \u2014 keeps natural dynamics\n      var vComp = ctx.createDynamicsCompressor();\n      vComp.threshold.value = -20.0;\n      vComp.knee.value      = 6.0;\n      vComp.ratio.value     = 4.0;\n      vComp.attack.value    = 0.004;\n      vComp.release.value   = 0.15;\n      var vMakeup = ctx.createGain(); vMakeup.gain.value = 1.7;\n\n      \/\/ Parallel comp \u2014 slams flat for sum (NY trick)\n      var vParaComp = ctx.createDynamicsCompressor();\n      vParaComp.threshold.value = -36.0;\n      vParaComp.knee.value      = 4.0;\n      vParaComp.ratio.value     = 12.0;\n      vParaComp.attack.value    = 0.002;\n      vParaComp.release.value   = 0.10;\n      var vParaGain = ctx.createGain(); vParaGain.gain.value = 0.55;\n\n      \/\/ Procedural short reverb impulse\n      var verbDur = 0.45;\n      var verbLen = Math.floor(ctx.sampleRate * verbDur);\n      var verbBuf = ctx.createBuffer(2, verbLen, ctx.sampleRate);\n      for (var ch = 0; ch < 2; ch++){\n        var data = verbBuf.getChannelData(ch);\n        var prev = 0;\n        for (var i = 0; i < verbLen; i++){\n          var t = i \/ verbLen;\n          var env2 = Math.pow(1 - t, 2.5);\n          var rnd = (Math.random() * 2 - 1) * env2;\n          prev = prev * 0.45 + rnd * 0.55;\n          data[i] = prev * 0.5;\n        }\n      }\n      var verb = ctx.createConvolver();\n      verb.buffer = verbBuf;\n      var verbSend = ctx.createGain(); verbSend.gain.value = 0.08;\n\n      \/\/ Wire: vS -> hpf -> body -> pres -> [de-ess split] -> voiceMix\n      vS.connect(hpf).connect(body).connect(pres);\n      \/\/ De-ess paths\n      pres.connect(deessLow).connect(voiceMix);\n      pres.connect(deessHigh).connect(deessComp).connect(deessOut).connect(voiceMix);\n      \/\/ From voiceMix: dry + parallel + reverb send\n      voiceMix.connect(vComp).connect(vMakeup).connect(master);\n      voiceMix.connect(vParaComp).connect(vParaGain).connect(master);\n      vMakeup.connect(verbSend).connect(verb).connect(master);\n\n      vS.start(vStart);\n    }\n\n    \/\/ ---- Second piece (customer's chosen library SFX) with v1.5 TRANSIENT\n    \/\/      DESIGNER \u2014 placed at the natural pause, or at the close as fallback.\n    \/\/      A single-piece sweeper\/drop has NO second piece, so nothing fires here. ----\n    if (secondBuf) {\n      var eS = ctx.createBufferSource(); eS.buffer = secondBuf;\n      var eTD = ctx.createDynamicsCompressor();\n      eTD.threshold.value = -8.0;\n      eTD.knee.value      = 0.0;\n      eTD.ratio.value     = 6.0;\n      eTD.attack.value    = 0.001;\n      eTD.release.value   = 0.04;\n      var eG = ctx.createGain();\n      var eLevel = secondAtClose ? 0.95 : 0.85;\n      eG.gain.setValueAtTime(eLevel, 0);\n      \/* RJ24-EDIT (Aug 2026): fade a long closer out at the end of the piece\n         instead of letting it run to its natural length. Short one-shots are\n         untouched - closePlay equals their full duration. *\/\n      if (secondAtClose && closePlay > 0 && closePlay < secondDur - 0.05) {\n        eG.gain.setValueAtTime(eLevel, Math.max(0.01, secondAt + closePlay - 0.45));\n        eG.gain.linearRampToValueAtTime(0.0, secondAt + closePlay);\n      }\n      eS.connect(eTD).connect(eG).connect(master);\n      eS.start(secondAt);\n    }\n\n    \/\/ v1.5 synthesised closing layers \u2014 ONLY when a piece actually CLOSES the file\n    \/\/ (the no-natural-pause fallback). With a single piece, or a 2nd piece placed\n    \/\/ mid-read, we add NO end hit \u2014 that unrequested end thud was the old\n    \/\/ \"played twice\" feel Paul flagged. Skipped entirely when noSfx.\n    if (!noSfx && secondAtClose) {\n      var closeImp = makeImpact(0.20);\n      var cIS = ctx.createBufferSource(); cIS.buffer = closeImp;\n      var cIG = ctx.createGain(); cIG.gain.value = 0.55;\n      cIS.connect(cIG).connect(master);\n      cIS.start(secondAt);\n\n      var closeSub = makeSubDrop(0.40);\n      var cSS = ctx.createBufferSource(); cSS.buffer = closeSub;\n      var cSG = ctx.createGain(); cSG.gain.value = 0.50;\n      cSS.connect(cSG).connect(master);\n      cSS.start(secondAt);\n    }\n\n    return ctx.startRendering().then(function(r){ return bufferToWavUrl(r); });\n  }\n\n  var sweeperTool = buildSfxTool('sw', { noun:'sweeper' });\n  var djdropTool  = buildSfxTool('dj', { noun:'DJ drop' });\n\n  \/* RJ24-EDIT (Aug 2026): the Advert and Podcast bed pickers are built HERE, not\n     up with their own tab code. buildSfxPicker reads LIB_SLOTS, which is a `var`\n     declared further down this file - calling it earlier gets the hoisted\n     `undefined` and throws, taking the whole script (and therefore every tab)\n     down with it. Found by loading the page rather than reading it. *\/\n  \/* The two calls live at the very END of the script - see the block below. *\/\n\n  \/* ===================== PODCAST INTRO\/OUTRO ===================== *\/\n  var pc = { mode:'intro', voice:null, genre:'corporate', delivery:'balanced', pace:'normal', musicLevel:'medium', busy:false };\n  var PC_SECS = CFG.podcastMusicSeconds || 30;\n\n  \/\/ genres reuse the advert GENRES list\n  (function(){\n    var w=el('pc-genres'); if(!w) return;\n    GENRES.forEach(function(g,i){ var d=document.createElement('div'); d.className='ais-genre'+(i===0?' selected':''); d.setAttribute('data-g',g.key); d.innerHTML='<div class=\"ais-genre-ico\">'+(g.ico||'\ud83c\udfb5')+'<\/div><div class=\"ais-genre-name\">'+g.label+'<\/div><div class=\"ais-genre-desc\">'+(g.desc||'')+'<\/div>'; d.addEventListener('click',function(){ w.querySelectorAll('.ais-genre').forEach(function(x){x.classList.remove('selected');}); d.classList.add('selected'); pc.genre=g.key; el('pc-prompt-wrap').classList.toggle('show', g.key==='custom'); }); w.appendChild(d); });\n  })();\n  \/\/ mode toggle\n  el('pc-mode').addEventListener('click', function(e){ var b=e.target.closest('button'); if(!b) return; el('pc-mode').querySelectorAll('button').forEach(function(x){x.classList.remove('active');}); b.classList.add('active'); pc.mode=b.getAttribute('data-pc'); el('pc-when').textContent = pc.mode==='intro'?'after your intro':'before your outro, fading in'; el('pc-loadkind').textContent=pc.mode; el('pc-readykind').textContent=pc.mode; });\n  wireMusicMode('pc', pc, function(){ pcRefresh(); });\n  if (el('pc-seclabel')) el('pc-seclabel').textContent = PC_SECS;\n  (function(){ var ml=el('pc-musiclevel'); if(ml) ml.addEventListener('click',function(e){ var b=e.target.closest('button'); if(!b) return; ml.querySelectorAll('button').forEach(function(x){x.classList.remove('active');}); b.classList.add('active'); pc.musicLevel=b.getAttribute('data-m')||'medium'; }); })();\n  \/\/ word count + cost\n  function pcCost(){\n    if (freeFirstAvailable()) return 0;\n    \/\/ Podcast intro\/outro always includes a music bed, so it's priced like an\n    \/\/ advert-with-music: base cost + the music surcharge (min ~4 credits).\n    var base;\n    if (pc.inputMode==='record'){ base=Math.max(1,Math.ceil((pc.recSeconds||8)\/(CFG.stsSecondsPerCredit||30))); }\n    else { var t=el('pc-script').value.trim(); var w=t?t.split(\/\\s+\/).filter(Boolean).length:0; base=Math.max(1,Math.ceil((w||1)\/WPC)); }\n    \/* Was opening at 2 credits because the surcharge only came off once a\n       bed had been picked - and the page opens before you have picked one. *\/\n    if (pc.musicMode !== 'composed') return base;\n    return base + MUSIC_COST;\n  }\n  function pcRefresh(){\n    var isRec=(pc.inputMode==='record');\n    var base, detail;\n    if (isRec){ base=Math.max(1,Math.ceil((pc.recSeconds||8)\/(CFG.stsSecondsPerCredit||30))); detail=base+' for ~'+Math.round(pc.recSeconds||0)+'s recording + '+MUSIC_COST+' for music'; }\n    else {\n      var t=el('pc-script').value.trim(); var w=t?t.split(\/\\s+\/).filter(Boolean).length:0; var s=w?Math.round((w\/(pc.pace==='slow'?2.2:pc.pace==='fast'?3.0:2.6))*10)\/10:0;\n      if(el('pc-count')) el('pc-count').innerHTML=w+' word'+(w===1?'':'s')+' &middot; \u2248 '+s+' sec voice';\n      base=Math.max(1,Math.ceil((w||1)\/WPC)); detail=base+' for ~'+w+' words + '+MUSIC_COST+' for music';\n    }\n    var pcOur = (pc.musicMode !== 'composed');\n    if (pcOur) { detail = detail.replace(\/ \\+ \\d+ for music\/, '') + ' \\u00b7 our music, free'; }\n    var total = pcOur ? base : (base + MUSIC_COST);\n    var c=el('pc-cost');\n    if(c){\n      if (freeFirstAvailable()){\n        c.innerHTML = 'Cost: <b style=\"color:#059669;\">FREE<\/b> <span style=\"color:#94A3B8;\">(your first creation \u2014 normally '+total+' credit'+(total===1?'':'s')+')<\/span>';\n      } else {\n        c.innerHTML='Cost: <b>'+total+' credit'+(total===1?'':'s')+'<\/b> <span style=\"color:#94A3B8;\">('+detail+')<\/span>';\n      }\n      renderFreeFirstNotice('pc-cost');\n    }\n    if (typeof updateFooterText==='function' && document.querySelector('#rj-ai-studio .ais-tab.active') && document.querySelector('#rj-ai-studio .ais-tab.active').getAttribute('data-tool')==='podcast'){ updateFooterText({ tool:'podcast', cost: (freeFirstAvailable()?0:total) }); }\n  }\n  window.RJ24PcRefresh = function(){ updateFooterText({ tool:'podcast', cost: pcCost() }); };\n  el('pc-script').addEventListener('input', pcRefresh);\n  (function(){ var dd=el('pc-delivery'),pp=el('pc-pace'); if(dd) dd.addEventListener('click',function(e){var b=e.target.closest('button');if(!b)return;dd.querySelectorAll('button').forEach(function(x){x.classList.remove('active');});b.classList.add('active');pc.delivery=b.getAttribute('data-d');}); if(pp) pp.addEventListener('click',function(e){var b=e.target.closest('button');if(!b)return;pp.querySelectorAll('button').forEach(function(x){x.classList.remove('active');});b.classList.add('active');pc.pace=b.getAttribute('data-p');pcRefresh();}); })();\n  pcRefresh();\n\n  \/\/ podcast voice picker\n  var pcVWrap=el('pc-voices'); var pcCurA=null,pcCurB=null;\n  (function(){ var ctrls=el('pc-voice-controls'); if(!ctrls) return; var cOpts='<option value=\"\">All countries<\/option>'+countries.map(function(c){return '<option value=\"'+c+'\">'+c+'<\/option>';}).join(''); var tOpts='<option value=\"\">All types<\/option>'+types.map(function(t){return '<option value=\"'+t+'\">'+t+'<\/option>';}).join(''); ctrls.innerHTML='<input type=\"text\" id=\"pc-voice-search\" class=\"ais-input\" placeholder=\"Search voices\u2026\" style=\"flex:2;min-width:160px;\"><select id=\"pc-voice-country\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+cOpts+'<\/select><select id=\"pc-voice-type\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+tOpts+'<\/select>'; })();\n  function pcRenderVoices(){ var q=(el('pc-voice-search')||{}).value||'', fc=(el('pc-voice-country')||{}).value||'', ft=(el('pc-voice-type')||{}).value||''; q=q.toLowerCase().trim(); pcVWrap.innerHTML=''; if(!q&&!fc&&!ft){ pcVWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:14px;margin:0;padding:8px 0;\">Choose a country or type above (or search) to see voices.<\/p>'; return; } var shown=VOICES.filter(function(v){ if(fc&&v.country!==fc) return false; if(ft&&(v.type||'').split(' \u00b7 ')[0]!==ft) return false; if(q&&(v.name+' '+v.meta+' '+v.country).toLowerCase().indexOf(q)===-1) return false; return true; }); if(!shown.length){ pcVWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:13px;margin:0;\">No voices match.<\/p>'; return; } var groups={}; shown.forEach(function(v){ var k=v.country||'Other'; (groups[k]=groups[k]||[]).push(v); }); sortCountries(Object.keys(groups)).forEach(function(country){ var h=document.createElement('div'); h.className='ais-voice-group'; h.textContent=country+' ('+groups[country].length+')'; pcVWrap.appendChild(h); groups[country].forEach(function(v){ pcVWrap.appendChild(pcCard(v)); }); }); }\n  function pcCard(v){ var d=document.createElement('div'); d.className='ais-voice'; d.setAttribute('data-v',v.id); if(pc.voice&&v.id===pc.voice) d.classList.add('selected'); d.innerHTML='<button type=\"button\" class=\"ais-voice-play\">&#9658;<\/button><span class=\"ais-voice-meta\"><b>'+v.name+'<\/b><small>'+(v.meta||v.type||'')+'<\/small><\/span>'; d.addEventListener('click',function(e){ if(e.target.closest('.ais-voice-play')) return; pcVWrap.querySelectorAll('.ais-voice').forEach(function(x){x.classList.remove('selected');}); d.classList.add('selected'); pc.voice=v.id; }); var pb=d.querySelector('.ais-voice-play'); pb.addEventListener('click',function(e){ e.preventDefault(); e.stopPropagation(); RJ24Preview.toggle(v, pb); }); return d; }\n  ['pc-voice-search','pc-voice-country','pc-voice-type'].forEach(function(id){ var n=el(id); if(n){ n.addEventListener('input',pcRenderVoices); n.addEventListener('change',pcRenderVoices); } });\n  pcRenderVoices();\n\n  \/\/ RJ24-EDIT (Jul 2026): Type \/ Record toggle for the podcast tab. Uses\n  \/\/ modeProp:'inputMode' because pc.mode already means intro\/outro here.\n  wireModeToggle({\n    toggleId:'pc-modetoggle', barId:'pc-modebar', typeStepId:'pc-type-step', recordStepId:'pc-record-step',\n    voicenoteId:'pc-record-voicenote', createBtnId:'pc-create', modeProp:'inputMode',\n    typeLabel:'Create my podcast audio', recordLabel:'Create from my recording',\n    cap: RECORD_CAPS.podcast, state: pc,\n    onReady:function(){ pcRefresh(); }, onModeChange:function(){ pcRefresh(); }\n  });\n\n  function pcLoading(on){ el('pc-result').classList.add('show'); el('pc-result-loading').style.display=on?'block':'none'; el('pc-result-ready').style.display=on?'none':'block'; el('pc-result').scrollIntoView({behavior:'smooth',block:'center'}); }\n  function pcFail(e){ pcLoading(false); el('pc-result').classList.remove('show'); var b=el('pc-create'); b.disabled=false; b.textContent=(pc.inputMode==='record'?'Create from my recording':'Create my podcast audio'); pc.busy=false; toast((e&&e.message)?e.message:'Could not create the podcast audio.','error'); }\n\n  el('pc-create').addEventListener('click', function(){\n    if(pc.busy) return;\n    if(pc.inputMode==='record'){ if(!pc.recBlob){ toast('Record or upload your line first.','error'); return; } }\n    else if(!el('pc-script').value.trim()){ toast('Type your script.','error'); return; }\n    if(!pc.voice){ toast('Choose a voice.','error'); return; }\n    if(pc.genre==='custom' && !el('pc-prompt').value.trim()){ toast('Describe the music you want.','error'); return; }\n    if(!DEMO){ var c=pcCost(); if(!CFG.loggedIn){ toast('One step: an email and a password, and it makes it.',''); if(window.RJ24OpenLogin){ setTimeout(function(){window.RJ24OpenLogin('login');},600); } else if(CFG.accountUrl){ setTimeout(function(){window.location.href=CFG.accountUrl;},1200); } return; } if((CFG.credits||0)<c){ showCreditsShort(c); return; } }\n    pc.busy=true; var b=el('pc-create'); b.disabled=true; b.textContent='Creating\u2026'; pcLoading(true);\n    var isRec=(pc.inputMode==='record');\n    var script=isRec?'':cleanScript(el('pc-script').value.trim());\n    \/\/ Music must be ONE piece covering the whole thing (no looping): the voice window\n    \/\/ PLUS the full instrumental section (PC_SECS). Estimate voice length generously.\n    var vEst = isRec ? (pc.recSeconds||8) : (script.split(\/\\s+\/).filter(Boolean).length \/ (pc.pace==='slow'?2.2:pc.pace==='fast'?3.0:2.6));\n    var musicSecs = Math.min(300, Math.ceil(PC_SECS + vEst + 4));\n    var ctx=new (window.AudioContext||window.webkitAudioContext)();\n    var H={ 'Content-Type':'application\/json' }; if(CFG.nonce) H['X-WP-Nonce']=CFG.nonce;\n    var creditsAfter=null;\n    var wasFreeFirst=false;\n    \/\/ Typed -> \/tts; recorded -> \/speech-to-speech (podcast always carries a music\n    \/\/ bed, so withMusic:true). Downstream mixing\/saving is identical.\n    var pcVoiceJob = isRec\n      ? stsConvert(pc.recBlob, pc.voice, pc.recSeconds, { tool:'podcast', withMusic:(pc.musicMode === 'composed'), noSfx:true })\n      : fetch(CFG.ttsUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ text:script, voice:pc.voice, delivery:pc.delivery, pace:pc.pace, tool:'podcast', withMusic:(pc.musicMode === 'composed'), pronounce:(el('pc-pronounce')||{}).value||'', voiceHints:{ noBreaths:true, finalCadenceDeclarative:true } })}).then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Voice failed'); return j; }); });\n    var pcBedPick = (pc.musicMode !== 'composed' && pc.pick && pc.pick.bed) ? pc.pick.bed : null;\n    \/* Only compose when the customer actually chose composed music. In\n       library mode with nothing picked we used to compose a track anyway,\n       having told the server withMusic:false - generating music nobody paid\n       for. Voice-only is the honest outcome there. *\/\n    var pcMusicJob = pcBedPick\n      ? Promise.resolve(pcBedPick.audioUrl)\n      : (pc.musicMode === 'composed'\n          ? startMusic(JSON.stringify({ genre:pc.genre, prompt:(el('pc-prompt')||{}).value ? el('pc-prompt').value.trim() : '', seconds:musicSecs, partOfTts:true }), H)\n          : Promise.resolve(null));\n    Promise.all([\n      pcVoiceJob,\n      pcMusicJob\n    ]).then(function(res){\n      if(res[0]&&typeof res[0].credits!=='undefined') creditsAfter=res[0].credits;\n      if(res[0] && res[0].freeFirst){ wasFreeFirst = true; CFG.freeFirstAvailable = false; }\n      if(res[0] && res[0].sessionToken){ pc.sessionToken = res[0].sessionToken; } \/\/ RJ24-EDIT (Jul 2026): takes picker\n      \/* RJ24-EDIT (Nov 2026): persist dry voice URL + script + music vibe so the\n         \"Get RJ24 to produce it\" upsell can post them to \/produce. *\/\n      pc.lastUrl    = (res[0] && res[0].audioUrl) || null;\n      pc.lastScript = isRec ? '[Recorded \u2014 Say It Yourself]' : script;\n      pc.lastGenre  = pc.genre || '';\n      return Promise.all([ res[0].audioUrl?fetchBuffer(ctx,res[0].audioUrl):null, res[1]?fetchBuffer(ctx,res[1]):null ]); })\n      .then(function(bufs){\n        pc.lastMusicBuf = bufs[1]; \/\/ RJ24-EDIT (Jul 2026, multi-take): reused by retakes\n        return mixPodcast(bufs[0], bufs[1], pc.mode, pc.musicLevel, PC_SECS).then(function(url){ return { url: url, voiceBuf: bufs[0] }; });\n      })\n      .then(function(out){\n        var url = out.url;\n        el('pc-audio').src=url;\n        var dl=el('pc-dl'); if(dl) dl.href=url;\n        pcLoading(false); b.disabled=false; b.textContent='Create again'; pc.busy=false;\n        pc.lastVoiceBuf = out.voiceBuf;\n        attachMixEditorButton({\n          resultId:'pc-result-ready', audioId:'pc-audio', dlId:'pc-dl',\n          parts: function(){\n            var P = podcastPlan(pc.lastVoiceBuf, pc.lastMusicBuf, pc.mode, pc.musicLevel, PC_SECS);\n            var out = [];\n            if (pc.lastMusicBuf){\n              var mLen = Math.min(pc.lastMusicBuf.duration, P.total);\n              out.push({ id:'bed', name:'Music', buf:pc.lastMusicBuf, start:0, trimA:0, trimB:mLen,\n                env:envClip(envRel(P.musicEnv, 0), mLen) });\n            }\n            if (pc.lastVoiceBuf) out.push({ id:'voice', name:'Your voice', buf:pc.lastVoiceBuf,\n              start:P.vStart, trimA:0, trimB:pc.lastVoiceBuf.duration,\n              env:[{t:0,g:1},{t:pc.lastVoiceBuf.duration,g:1}] });\n            return out;\n          }\n        });\n        attachFreeMusicBar({\n          resultId:'pc-result-ready', pickerId:'pc-sfx-picker',\n          audioId:'pc-audio', dlId:'pc-dl',\n          isComposed: function(){ return pc.musicMode === 'composed'; },\n          remix: function(){\n            var p = pc.pick || {};\n            if (!pc.lastVoiceBuf) return null;\n            var ctx2 = new (window.AudioContext||window.webkitAudioContext)();\n            return libBuffer(ctx2, p.bed).then(function(bed){\n              if (bed) { pc.lastMusicBuf = bed; }\n              return mixPodcast(pc.lastVoiceBuf, pc.lastMusicBuf, pc.mode, pc.musicLevel, PC_SECS);\n            });\n          }\n        });\n        if (wasFreeFirst) showFreeFirstBadge('pc-result-ready');\n        if(creditsAfter!==null){ CFG.credits=creditsAfter; updateFooterText({}); }\n        pcRefresh();\n        \/\/ RJ24-EDIT (Jul 2026, multi-take): seed the takes picker (typed only \u2014\n        \/\/ a fixed recording has no re-performable take).\n        if (pc.inputMode==='record'){\n          if (pc.takesCtrl) pc.takesCtrl.reset();\n        } else {\n          if (!pc.takesCtrl) {\n            pc.takesCtrl = createTakesController({\n              key: 'pc',\n              hostEl: el('pc-result-ready'),\n              audioEl: el('pc-audio'),\n              dlEl: el('pc-dl'),\n              getSessionToken: function(){ return pc.sessionToken; },\n              remix: function(voiceBuf){ return mixPodcast(voiceBuf, pc.lastMusicBuf, pc.mode, pc.musicLevel, PC_SECS); },\n              onSelect: function(url2, voiceBuf, rawUrl){ pc.lastUrl = rawUrl; }\n            });\n          }\n          pc.takesCtrl.seed(url, out.voiceBuf, pc.lastUrl);\n        }\n        \/* v1.6.1: save server-side *\/\n        if (window.RJ24SaveCreation && CFG.loggedIn) {\n          var pcVoice = (VOICES.find ? VOICES.find(function(v){return v.id===pc.voice;}) : null) || {};\n          var pcSaveMeta = {\n            tool: 'podcast',\n            script: isRec ? '[Recorded \u2014 Say It Yourself]' : el('pc-script').value.trim(),\n            voice_name: pcVoice.name || '',\n            voice_country: pcVoice.country || '',\n            sfx: '',\n            sting: true,\n            sting_genre: pc.genre || '',\n            sting_tempo: '',\n            duration: 0\n          };\n          if (!el('pc-save-pill')) {\n            var pill = document.createElement('div');\n            pill.id = 'pc-save-pill';\n            pill.className = 'ais-save-pill';\n            pill.style.display = 'none';\n            var resReady = el('pc-result-ready');\n            if (resReady) resReady.appendChild(pill);\n          }\n          setTimeout(function(){\n            pcSaveMeta.duration = (el('pc-audio') && el('pc-audio').duration) || 0;\n            window.RJ24SaveCreation(url, pcSaveMeta, 'pc-save-pill');\n          }, 800);\n        }\n      })\n      .catch(function(e){ pcFail(e); });\n  });\n\n  \/* RJ24-EDIT (Nov 2026): podcast \"Get RJ24 to produce it\" upsell button.\n     Same \/produce endpoint as the advert + SFX tools; sends tool='podcast'\n     so the order item name and admin email say \"podcast intro\/outro\". *\/\n  (function wirePcProduce(){\n    var pcProdBtn = el('pc-produce-btn');\n    if (!pcProdBtn) return;\n    pcProdBtn.addEventListener('click', function(){\n      if (typeof DEMO !== 'undefined' && DEMO){ toast('Production orders work once AI Studio is live (out of demo mode).'); return; }\n      if (!CFG.loggedIn){ toast('Please log in first.','error'); return; }\n      if (!pc.lastUrl){ toast('Create your podcast audio first.','error'); return; }\n      pcProdBtn.disabled = true;\n      var origLabel = pcProdBtn.textContent;\n      pcProdBtn.textContent = 'Creating order\u2026';\n      var H = { 'Content-Type':'application\/json' }; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n      fetch(CFG.produceUrl, {\n        method:'POST', credentials:'same-origin', headers:H,\n        body: JSON.stringify({\n          tool: 'podcast',\n          voiceUrl: pc.lastUrl,\n          script: pc.lastScript || '',\n          genre: pc.lastGenre || ''\n        })\n      }).then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Could not create order'); return j; }); })\n        .then(function(j){\n          toast('Order created! Taking you to checkout\u2026');\n          if (j.payUrl) setTimeout(function(){ window.location.href = j.payUrl; }, 1200);\n        })\n        .catch(function(e){ toast(e.message || 'Could not create the order.','error'); pcProdBtn.disabled = false; pcProdBtn.textContent = origLabel; });\n    });\n    var pcFee = el('pc-produce-fee');\n    if (pcFee && CFG.produceFee) pcFee.textContent = CFG.produceFee;\n  })();\n\n  \/* ===================== RJ24-EDIT (Jul 2026): SAY IT YOURSELF ===================== *\/\n  (function sayItYourselfTab(){\n    if (!CFG.stsEnabled) return;\n    var styVoiceWrap = el('sty-voices');\n    if (!styVoiceWrap) return; \/\/ panel not present (shouldn't happen, but be safe)\n\n    var sty = { voice:null, blob:null, seconds:0, busy:false };\n    window.RJ24StyRefresh = function(){ updateFooterText({ tool:'sty', cost: styCost() }); };\n\n    function styCost(){\n      if (freeFirstAvailable()) return 0;\n      var per = CFG.stsSecondsPerCredit || 30;\n      return Math.max(1, Math.ceil((sty.seconds || 8) \/ per));\n    }\n    function styRefreshCost(){\n      var c = el('sty-cost');\n      var total = Math.max(1, Math.ceil((sty.seconds || 8) \/ (CFG.stsSecondsPerCredit || 30)));\n      if (c) {\n        if (freeFirstAvailable()){\n          c.innerHTML = 'Cost: <b style=\"color:#059669;\">FREE<\/b> <span style=\"color:#94A3B8;\">(your first creation \u2014 normally '+total+' credit'+(total===1?'':'s')+')<\/span>';\n        } else if (!sty.seconds){\n          \/\/ RJ24-EDIT (Aug 2026): don't quote a breakdown of a recording that\n          \/\/ does not exist yet. 1 credit per 30 seconds, said plainly.\n          c.innerHTML = 'Cost: <b>1 credit<\/b> <span style=\"color:#94A3B8;\">per 30 seconds \u2014 record something and this updates<\/span>';\n        } else {\n          c.innerHTML = 'Cost: <b>'+total+' credit'+(total===1?'':'s')+'<\/b> <span style=\"color:#94A3B8;\">for your '+Math.round(sty.seconds)+'-second recording<\/span>';\n        }\n        renderFreeFirstNotice('sty-cost');\n      }\n      if (document.querySelector('#rj-ai-studio .ais-tab.active') && document.querySelector('#rj-ai-studio .ais-tab.active').getAttribute('data-tool')==='sty'){\n        updateFooterText({ tool:'sty', cost: (freeFirstAvailable() ? 0 : total) });\n      }\n    }\n    styRefreshCost();\n\n    \/\/ ---- Voice picker (reuses the shared VOICES list) ----\n    (function(){ var ctrls=el('sty-voice-controls'); if(!ctrls) return;\n      var cOpts='<option value=\"\">All countries<\/option>'+countries.map(function(c){return '<option value=\"'+c+'\">'+c+'<\/option>';}).join('');\n      var tOpts='<option value=\"\">All types<\/option>'+types.map(function(t){return '<option value=\"'+t+'\">'+t+'<\/option>';}).join('');\n      ctrls.innerHTML='<input type=\"text\" id=\"sty-voice-search\" class=\"ais-input\" placeholder=\"Search voices\u2026\" style=\"flex:2;min-width:160px;\"><select id=\"sty-voice-country\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+cOpts+'<\/select><select id=\"sty-voice-type\" class=\"ais-input\" style=\"flex:1;min-width:120px;\">'+tOpts+'<\/select>';\n    })();\n    function styRenderVoices(){\n      var q=(el('sty-voice-search')||{}).value||'', fc=(el('sty-voice-country')||{}).value||'', ft=(el('sty-voice-type')||{}).value||'';\n      q=q.toLowerCase().trim(); styVoiceWrap.innerHTML='';\n      if(!q&&!fc&&!ft){ styVoiceWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:14px;margin:0;padding:8px 0;\">Choose a country or type above (or search) to see voices.<\/p>'; return; }\n      var shown=VOICES.filter(function(v){ if(fc&&v.country!==fc) return false; if(ft&&(v.type||'').split(' \u00b7 ')[0]!==ft) return false; if(q&&(v.name+' '+v.meta+' '+v.country).toLowerCase().indexOf(q)===-1) return false; return true; });\n      if(!shown.length){ styVoiceWrap.innerHTML='<p style=\"grid-column:1\/-1;color:#94A3B8;font-size:13px;margin:0;\">No voices match.<\/p>'; return; }\n      var groups={}; shown.forEach(function(v){ var k=v.country||'Other'; (groups[k]=groups[k]||[]).push(v); });\n      sortCountries(Object.keys(groups)).forEach(function(country){ var h=document.createElement('div'); h.className='ais-voice-group'; h.textContent=country+' ('+groups[country].length+')'; styVoiceWrap.appendChild(h); groups[country].forEach(function(v){ styVoiceWrap.appendChild(styCard(v)); }); });\n    }\n    function styCard(v){\n      var d=document.createElement('div'); d.className='ais-voice'; d.setAttribute('data-v',v.id);\n      if(sty.voice&&v.id===sty.voice) d.classList.add('selected');\n      d.innerHTML='<button type=\"button\" class=\"ais-voice-play\">&#9658;<\/button><span class=\"ais-voice-meta\"><b>'+v.name+'<\/b><small>'+(v.meta||v.type||'')+'<\/small><\/span>';\n      d.addEventListener('click',function(e){ if(e.target.closest('.ais-voice-play')) return; styVoiceWrap.querySelectorAll('.ais-voice').forEach(function(x){x.classList.remove('selected');}); d.classList.add('selected'); sty.voice=v.id; });\n      var pb=d.querySelector('.ais-voice-play');\n      pb.addEventListener('click',function(e){ e.preventDefault(); e.stopPropagation(); RJ24Preview.toggle(v, pb); });\n      return d;\n    }\n    ['sty-voice-search','sty-voice-country','sty-voice-type'].forEach(function(id){ var n=el(id); if(n){ n.addEventListener('input',styRenderVoices); n.addEventListener('change',styRenderVoices); } });\n    styRenderVoices();\n\n    \/\/ ---- Recording (MediaRecorder) ----\n    var mediaRecorder = null, chunks = [], recTimer = null, recStart = 0;\n    var recordBtn = el('sty-record-btn');\n    var statusEl = el('sty-record-status');\n    var timeEl = el('sty-record-time');\n    var previewEl = el('sty-record-preview');\n    var recordAudio = el('sty-record-audio');\n    var againBtn = el('sty-record-again');\n\n    function fmtTime(s){ var m = Math.floor(s\/60); var ss = Math.floor(s%60); return m + ':' + (ss<10?'0':'')+ss; }\n\n    function startRecording(){\n      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){\n        toast('Recording isn\\u2019t supported in this browser \u2014 try uploading a file instead.','error'); return;\n      }\n      navigator.mediaDevices.getUserMedia({ audio:true }).then(function(stream){\n        chunks = [];\n        var mimeType = MediaRecorder.isTypeSupported('audio\/webm') ? 'audio\/webm' : '';\n        mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType:mimeType }) : new MediaRecorder(stream);\n        mediaRecorder.addEventListener('dataavailable', function(e){ if (e.data && e.data.size > 0) chunks.push(e.data); });\n        mediaRecorder.addEventListener('stop', function(){\n          stream.getTracks().forEach(function(t){ t.stop(); });\n          var blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio\/webm' });\n          onRecordingDone(blob);\n        });\n        mediaRecorder.start();\n        recordBtn.classList.add('recording');\n        recordBtn.innerHTML = '&#9632;';\n        statusEl.textContent = 'Recording\u2026 tap to stop';\n        timeEl.style.display = 'block';\n        previewEl.style.display = 'none';\n        recStart = Date.now();\n        recTimer = setInterval(function(){\n          var elapsed = (Date.now() - recStart) \/ 1000;\n          \/\/ RJ24-EDIT (Aug 2026): show what the limit IS, and say so when it is\n          \/\/ reached - the other four recorders already do both. The 60-second cap\n          \/\/ itself is unchanged.\n          timeEl.textContent = fmtTime(elapsed) + ' \/ 1:00';\n          if (elapsed >= 55 && elapsed < 60){ timeEl.style.color = '#B45309'; }\n          if (elapsed >= 60){\n            toast('Reached the 1 minute limit \u2014 recording stopped. That is everything up to a minute, safely captured.');\n            stopRecording();\n          } \/\/ hard cap \u2014 keeps files small and cost sane\n        }, 200);\n      }).catch(function(){\n        toast('Could not access your microphone \u2014 check your browser\\u2019s permission settings.','error');\n      });\n    }\n    function stopRecording(){\n      if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();\n      clearInterval(recTimer);\n      recordBtn.classList.remove('recording');\n      recordBtn.innerHTML = '&#9679;';\n    }\n    function onRecordingDone(blob){\n      sty.blob = blob;\n      var url = URL.createObjectURL(blob);\n      recordAudio.src = url;\n      previewEl.style.display = 'block';\n      statusEl.textContent = 'Recording captured \u2014 listen back, or record again';\n      timeEl.style.display = 'none';\n      \/\/ Measure duration via a throwaway AudioContext decode so pricing\/UI can use it.\n      var ctx = new (window.AudioContext||window.webkitAudioContext)();\n      blob.arrayBuffer().then(function(buf){ return ctx.decodeAudioData(buf); }).then(function(ab){\n        sty.seconds = ab.duration; styRefreshCost();\n      }).catch(function(){ sty.seconds = 8; styRefreshCost(); });\n    }\n    if (recordBtn) recordBtn.addEventListener('click', function(){\n      if (mediaRecorder && mediaRecorder.state === 'recording'){ stopRecording(); }\n      else { startRecording(); }\n    });\n    if (againBtn) againBtn.addEventListener('click', function(){\n      sty.blob = null; previewEl.style.display = 'none'; statusEl.textContent = 'Tap to start recording';\n    });\n    var uploadInput = el('sty-upload');\n    if (uploadInput) uploadInput.addEventListener('change', function(){\n      var file = uploadInput.files && uploadInput.files[0];\n      if (!file) return;\n      onRecordingDone(file);\n    });\n\n    function styBlobToBase64(blob){\n      return new Promise(function(resolve, reject){\n        var reader = new FileReader();\n        reader.onload = function(){ resolve(reader.result); };\n        reader.onerror = reject;\n        reader.readAsDataURL(blob);\n      });\n    }\n\n    function styLoading(on){ el('sty-result').classList.add('show'); el('sty-result-loading').style.display=on?'block':'none'; el('sty-result-ready').style.display=on?'none':'block'; el('sty-result').scrollIntoView({behavior:'smooth',block:'center'}); }\n    function styFail(e){ styLoading(false); el('sty-result').classList.remove('show'); var b=el('sty-create'); if(b){ b.disabled=false; b.textContent='Convert to this voice'; } sty.busy=false; toast((e&&e.message)?e.message:'Could not convert this recording.','error'); }\n\n    var createBtn = el('sty-create');\n    if (createBtn) createBtn.addEventListener('click', function(){\n      if (sty.busy) return;\n      if (!sty.blob){ toast('Record or upload your line first.','error'); return; }\n      if (!sty.voice){ toast('Choose a voice.','error'); return; }\n      if(!DEMO){\n        var c = styCost();\n        if(!CFG.loggedIn){ toast('One step: an email and a password, and it makes it.',''); if(window.RJ24OpenLogin){ setTimeout(function(){window.RJ24OpenLogin('login');},600); } else if(CFG.accountUrl){ setTimeout(function(){window.location.href=CFG.accountUrl;},1200); } return; }\n        if((CFG.credits||0) < c){ showCreditsShort(c); return; }\n      }\n      sty.busy = true; createBtn.disabled = true; createBtn.textContent = 'Converting\u2026'; styLoading(true);\n      var wasFreeFirst = false;\n      styBlobToBase64(sty.blob).then(function(b64){\n        var H = { 'Content-Type':'application\/json' }; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n        return fetch(CFG.stsUrl, { method:'POST', credentials:'same-origin', headers:H, body: JSON.stringify({ audio: b64, voice: sty.voice, seconds: sty.seconds || 8 }) });\n      }).then(function(r){ return r.json().then(function(j){ if(!r.ok) throw new Error(j.message||'Voice conversion failed'); return j; }); })\n        .then(function(j){\n          if (typeof j.credits !== 'undefined'){ CFG.credits = j.credits; updateFooterText({}); }\n          if (j.freeFirst){ wasFreeFirst = true; CFG.freeFirstAvailable = false; }\n          el('sty-audio').src = j.audioUrl;\n          var dl = el('sty-dl'); if (dl) dl.href = j.audioUrl;\n          styLoading(false); createBtn.disabled = false; createBtn.textContent = 'Convert to this voice'; sty.busy = false;\n          if (wasFreeFirst) showFreeFirstBadge('sty-result-ready');\n          styRefreshCost();\n          if (window.RJ24SaveCreation && CFG.loggedIn) {\n            var voiceObj = (VOICES.find ? VOICES.find(function(v){return v.id===sty.voice;}) : null) || {};\n            var saveMeta = { tool:'advert', script:'[Say It Yourself]', voice_name: voiceObj.name||'', voice_country: voiceObj.country||'', sfx:'', sting:false, sting_genre:'', sting_tempo:'', duration: sty.seconds||0 };\n            if (!el('sty-save-pill')) {\n              var pill = document.createElement('div'); pill.id = 'sty-save-pill'; pill.className = 'ais-save-pill'; pill.style.display = 'none';\n              var resReady = el('sty-result-ready'); if (resReady) resReady.appendChild(pill);\n            }\n            setTimeout(function(){ window.RJ24SaveCreation(j.audioUrl, saveMeta, 'sty-save-pill'); }, 400);\n          }\n        })\n        .catch(function(e){ styFail(e); });\n    });\n  })();\n\n  \/\/ Podcast mix \u2014 full music piece (NOT looped), positioned by mode.\n  function mixPodcast(voiceBuf, musicBuf, mode, level, tailSecs){\n    voiceBuf = rj24NormaliseVoice(voiceBuf);\n    \/* v1.3 (May 2026) \u2014 production-quality rewrite.\n       - Intro mode: music PRE-ROLLS 250ms at bed level BEFORE voice (was\n         starting at duck level immediately = the \"speaking with no music\"\n         complaint).\n       - Full broadcast vocal chain (HPF\/EQ\/compressor\/makeup) \u2014 previously\n         podcast had only a 1.25x gain, no compression at all.\n       - Master limiter (was missing).\n       - Music bus compressor (glues the bed).\n       - Duck bumped to 0.27 medium (~-11 dB) for broadcast standard.\n       - Fade tightened from 2.0s to 1.5s (long fade was inaudible halfway). *\/\n    var sr   = 44100;\n    var vDur = voiceBuf ? voiceBuf.duration : 0;\n    var fade = 1.5;\n    var TAIL = tailSecs || 30;\n\n    var LEVELS = {\n      low:    { bed: 0.55, duck: 0.18 },\n      medium: { bed: 0.75, duck: 0.27 },\n      high:   { bed: 0.90, duck: 0.38 }\n    };\n    var lv = LEVELS[level] || LEVELS.medium;\n    var P = podcastPlan(voiceBuf, musicBuf, mode, level, tailSecs);\n\n    var ctx, total;\n\n    \/\/ Helper to build master + music bus (shared between intro and outro)\n    function buildBuses(c){\n      var master = c.createGain(); master.gain.value = 1.0;\n      var limiter = c.createDynamicsCompressor();\n      limiter.threshold.value = -3.0;\n      limiter.knee.value      = 0.0;\n      limiter.ratio.value     = 20.0;\n      limiter.attack.value    = 0.002;\n      limiter.release.value   = 0.18;\n      master.connect(limiter).connect(c.destination);\n      var musicBus = c.createGain(); musicBus.gain.value = 1.0;\n      var musicComp = c.createDynamicsCompressor();\n      musicComp.threshold.value = -15.0;\n      musicComp.knee.value      = 6.0;\n      musicComp.ratio.value     = 3.0;\n      musicComp.attack.value    = 0.010;\n      musicComp.release.value   = 0.25;\n      musicBus.connect(musicComp).connect(master);\n      return { master: master, musicBus: musicBus };\n    }\n\n    \/\/ Helper to apply the broadcast vocal chain\n    function wireVoice(c, buf, startAt, master){\n      var vS = c.createBufferSource(); vS.buffer = buf;\n      var hpf  = c.createBiquadFilter();  hpf.type = 'highpass';  hpf.frequency.value = 90;\n      var body = c.createBiquadFilter();  body.type = 'peaking'; body.frequency.value = 220;  body.Q.value = 0.7; body.gain.value = 2.0;\n      var pres = c.createBiquadFilter();  pres.type = 'peaking'; pres.frequency.value = 4200; pres.Q.value = 0.8; pres.gain.value = 3.0;\n      var vComp = c.createDynamicsCompressor();\n      vComp.threshold.value = -20.0;\n      vComp.knee.value      = 6.0;\n      vComp.ratio.value     = 4.0;\n      vComp.attack.value    = 0.004;\n      vComp.release.value   = 0.15;\n      var vMakeup = c.createGain(); vMakeup.gain.value = 2.0;\n      vS.connect(hpf).connect(body).connect(pres).connect(vComp).connect(vMakeup).connect(master);\n      vS.start(startAt);\n    }\n\n    \/\/ Helper to skip leading silence in AI-generated music\n    function musicSilenceOffset(buf){\n      var off = 0;\n      try {\n        var ch = buf.getChannelData(0);\n        var srx = buf.sampleRate;\n        var maxScan = Math.min(ch.length, Math.floor(srx * 3));\n        for (var i = 0; i < maxScan; i++){\n          if (Math.abs(ch[i]) > 0.03){\n            off = Math.max(0, (i \/ srx) - 0.02);\n            break;\n          }\n        }\n      } catch(e){}\n      return off;\n    }\n\n    if (mode === 'intro') {\n      \/\/ v1.3: 250ms music pre-roll at BED before voice. Then voice ducks\n      \/\/ music. After voice, music rises to bed for TAIL secs, then fades.\n      var vStart = P.vStart;\n      var vEnd   = P.vEnd;\n      total      = P.total;\n\n      ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(\n        2, Math.ceil(sr * total), sr\n      );\n      var buses = buildBuses(ctx);\n\n      if (musicBuf) {\n        var mS = ctx.createBufferSource(); mS.buffer = musicBuf;\n        var mG = ctx.createGain();\n        \/\/ v1.3: open at BED (not duck). Pre-roll is bed-level music.\n        envApply(mG, P.musicEnv);\n        mS.connect(mG).connect(buses.musicBus);\n        mS.start(0, musicSilenceOffset(musicBuf));\n      }\n\n      if (voiceBuf) wireVoice(ctx, voiceBuf, vStart, buses.master);\n\n    } else {\n      \/\/ Outro: music fades in for 1.5s at bed, plays TAIL secs solo, then\n      \/\/ voice comes in (music ducks), continues to vEnd, then fades out.\n      \/* straight off the plan, so the read cannot land outside the music *\/\n      var vStart2 = P.vStart;\n      var vEnd2   = P.vEnd;\n      total = P.total;\n\n      ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(\n        2, Math.ceil(sr * total), sr\n      );\n      var buses2 = buildBuses(ctx);\n\n      if (musicBuf) {\n        var mS2 = ctx.createBufferSource(); mS2.buffer = musicBuf;\n        var mG2 = ctx.createGain();\n        envApply(mG2, P.musicEnv);\n        mS2.connect(mG2).connect(buses2.musicBus);\n        mS2.start(0, musicSilenceOffset(musicBuf));\n      }\n\n      if (voiceBuf) wireVoice(ctx, voiceBuf, vStart2, buses2.master);\n    }\n\n    return ctx.startRendering().then(function(r){ return bufferToWavUrl(r); });\n  }\n\n  \/* ===================== SUNG JINGLE TAB ===================== *\/\n  (function sungTab(){\n    var voiceSel = 'both', qtySel = 1;\n    var perJingle = (CFG.creditsPerJingle || 10);\n    \/\/ No length buttons \u2014 kie.ai has no hard duration control, so length is driven\n    \/\/ by how much the customer writes (short lyric = short ID, more = longer).\n    \/\/ voice pills\n    document.querySelectorAll('#sg-voice .ais-pill').forEach(function(p){\n      p.addEventListener('click', function(){\n        document.querySelectorAll('#sg-voice .ais-pill').forEach(function(x){ x.classList.remove('active'); });\n        p.classList.add('active'); voiceSel = p.getAttribute('data-voice');\n      });\n    });\n    \/\/ quantity pills\n    function updateQtyHint(){\n      var total = perJingle * qtySel;\n      var h = el('sg-qty-hint');\n      if (h) h.textContent = qtySel + ' jingle' + (qtySel>1?'s':'') + ' \u00d7 ' + perJingle + ' credits = ' + total + ' credits. No free redos on sung \u2014 a re-run costs the same again.';\n      if (tabActive('sung')) updateFooterText({ tool:'sung', cost: total });\n    }\n    function tabActive(t){ var a=document.querySelector('#rj-ai-studio .ais-tab.active'); return a && a.getAttribute('data-tool')===t; }\n    document.querySelectorAll('#sg-qty .ais-pill').forEach(function(p){\n      p.addEventListener('click', function(){\n        document.querySelectorAll('#sg-qty .ais-pill').forEach(function(x){ x.classList.remove('active'); });\n        p.classList.add('active'); qtySel = parseInt(p.getAttribute('data-qty'),10); updateQtyHint();\n      });\n    });\n    updateQtyHint();\n    window.RJ24SungRefresh = function(){ updateFooterText({ tool:'sung', cost: perJingle * qtySel }); };\n\n    \/* RJ24-EDIT (Nov 2026): live word-count warning on the slogan.\n       Suno typically sings cleanly up to ~25-30 words. Beyond that, it picks a\n       melody and may drop words to fit. Warn early so the customer can shorten\n       before they spend 10 credits and get partial output. *\/\n    var slogan = el('sg-slogan');\n    var sloganHint = el('sg-slogan-hint');\n    if (slogan && sloganHint) {\n      var defaultHintText = sloganHint.innerHTML;\n      slogan.addEventListener('input', function(){\n        var wc = (this.value.trim().match(\/\\S+\/g)||[]).length;\n        if (wc >= 30) {\n          sloganHint.innerHTML = '<span style=\"color:#DC2626;font-weight:700;\">\u26a0 ' + wc + ' words \u2014 too long for a single sung jingle. The singer will likely drop words. Shorten to under 25 words for best results.<\/span>';\n        } else if (wc >= 20) {\n          sloganHint.innerHTML = '<span style=\"color:#D97706;font-weight:700;\">' + wc + ' words \u2014 long. The singer may skip some. Under 20 words sings cleanest.<\/span>';\n        } else {\n          sloganHint.innerHTML = defaultHintText;\n        }\n      });\n    }\n\n    var btn = el('sg-create');\n    if (!btn) return;\n    var busy = false;\n\n    btn.addEventListener('click', function(){\n      if (busy) return;\n      var brand = (el('sg-brand').value||'').trim();\n      if (!brand){ toast('Please enter a station or business name.','error'); return; }\n\n      var totalCost = perJingle * qtySel;\n      \/\/ Login + credit gates. Sung uses kie.ai, NOT ElevenLabs, so not DEMO-gated.\n      \/\/ Free-first-creation doesn't apply to sung jingles (separate provider\/cost\n      \/\/ model) \u2014 always charged normally.\n      if(!CFG.loggedIn){ toast('One step: an email and a password, and it makes it.',''); if(window.RJ24OpenLogin){ setTimeout(function(){window.RJ24OpenLogin('login');},600);} else if(CFG.accountUrl){ setTimeout(function(){window.location.href=CFG.accountUrl;},1200);} return; }\n      if((CFG.credits||0) < totalCost){ showCreditsShort(totalCost); return; }\n\n      var payload = {\n        brandName: brand,\n        slogan: (el('sg-slogan').value||'').trim(),\n        style: el('sg-style').value,\n        voiceLead: voiceSel,\n        instrumental: el('sg-instrumental').checked,\n        freeText: (el('sg-free').value||'').trim(),\n        quantity: qtySel\n      };\n\n      busy = true; btn.disabled = true; btn.textContent = 'Starting\u2026';\n      var res = el('sg-result'); var inner = el('sg-result-inner');\n      res.classList.add('show');\n      inner.innerHTML = '<div class=\"ais-gen-spin\"><\/div><p style=\"margin-top:10px;color:var(--muted);\">Composing your '+(qtySel>1?qtySel+' jingles':'jingle')+' \u2014 this can take 30\u201390 seconds\u2026<\/p>';\n      res.scrollIntoView({ behavior:'smooth', block:'center' }); \/\/ v1.7.5: match the other tabs \u2014 scroll to the creation\n\n      \/\/ v1.6.4: build the AI Creations save metadata once, here, where we\n      \/\/ still have access to all the form values via the click closure. Each\n      \/\/ ready jingle uses this base and adds its own per-slot id for the\n      \/\/ save-status pill.\n      var saveMeta = {\n        tool: 'sung',\n        script: payload.brandName + (payload.slogan ? ' \u2014 ' + payload.slogan : ''),\n        voice_name: payload.instrumental\n                      ? 'Instrumental'\n                      : (payload.voiceLead === 'f' ? 'Female lead'\n                         : payload.voiceLead === 'm' ? 'Male lead' : 'Mixed'),\n        voice_country: '',\n        sfx: '',\n        sting: true,\n        sting_genre: payload.style || '',\n        sting_tempo: '',\n        duration: 0\n      };\n\n      var base = (CFG.jinglesBase || '\/wp-json\/rj24\/v1');\n      fetch(base + '\/generate', {\n        method:'POST', credentials:'same-origin',\n        headers:{ 'Content-Type':'application\/json', 'X-WP-Nonce': (CFG.wpNonce||'') },\n        body: JSON.stringify(payload)\n      }).then(function(r){ return r.json().then(function(j){ return {ok:r.ok, j:j}; }); })\n      .then(function(out){\n        if (!out.ok || !out.j || !out.j.taskIds || !out.j.taskIds.length){\n          var msg = (out.j && (out.j.message || out.j.msg)) || 'Could not start generation.';\n          inner.innerHTML = '<p style=\"color:#B91C1C;\">'+msg+'<\/p>';\n          busy=false; btn.disabled=false; btn.textContent='Create my jingle'; return;\n        }\n        if (typeof out.j.credits === 'number'){ CFG.credits = out.j.credits; updateQtyHint(); }\n        pollSungBatch(out.j.taskIds, inner, btn, saveMeta);\n      }).catch(function(){\n        inner.innerHTML = '<p style=\"color:#B91C1C;\">Network error. Please try again.<\/p>';\n        busy=false; btn.disabled=false; btn.textContent='Create my jingle';\n      });\n    });\n\n    \/\/ Poll a batch of task IDs; render each as it completes.\n    \/\/ v1.6.4: accepts saveMeta so each ready jingle auto-saves to AI Creations.\n    function pollSungBatch(taskIds, inner, btn, saveMeta){\n      var base = (CFG.jinglesBase || '\/wp-json\/rj24\/v1');\n      var done = {}; var slots = {};\n      inner.innerHTML = '';\n      taskIds.forEach(function(id, i){\n        var slot = document.createElement('div');\n        slot.className = 'sg-slot';\n        slot.innerHTML = '<div class=\"ais-gen-spin\" style=\"width:26px;height:26px;\"><\/div> <span style=\"color:var(--muted);font-size:13px;\">Jingle '+(i+1)+' \u2014 composing\u2026<\/span>';\n        inner.appendChild(slot);\n        slots[id] = slot;\n      });\n      var tries = 0, max = 70;\n      var iv = setInterval(function(){\n        tries++;\n        taskIds.forEach(function(id){\n          if (done[id]) return;\n          fetch(base + '\/status?taskId=' + encodeURIComponent(id), { credentials:'same-origin', headers:{ 'X-WP-Nonce':(CFG.wpNonce||'') } })\n          .then(function(r){ return r.json(); })\n          .then(function(s){\n            if (typeof s.credits === 'number'){ CFG.credits = s.credits; updateQtyHint(); }\n            if (s.state === 'ready' && s.audioUrl){\n              done[id] = true;\n              var pillId = 'sg-save-pill-' + id;\n              \/\/ v0.7.8 \/ 1.7.4: saving is now done SERVER-side (it survives a\n              \/\/ closed tab or a slow finish), reported back as s.saved. The\n              \/\/ browser no longer calls RJ24SaveCreation for sung jingles.\n              var savedMsg = s.saved ? '\\u2713 Saved to My Creations' : 'Saving to My Creations\\u2026';\n              slots[id].innerHTML = '<audio controls src=\"'+s.audioUrl+'\" style=\"width:100%;\"><\/audio>'+\n                '<div class=\"ais-downloads\" style=\"margin-top:8px;\"><a class=\"ais-dl-btn\" href=\"'+s.audioUrl+'\" download=\"jingle.mp3\">\u2b07 Download<\/a><\/div>'+\n                '<div class=\"ais-save-pill\" id=\"'+pillId+'\" style=\"margin-top:6px;font-size:12px;color:#067a46;font-weight:600;\">'+savedMsg+'<\/div>';\n            } else if (s.state === 'failed'){\n              done[id] = true;\n              var msg;\n              if (s.reason === 'lyrics_short'){\n                msg = '<b>Lyrics too short.<\/b> Your credits have been returned. Please try again with longer lyrics \u2014 add a slogan plus your station name (a few more words helps the singer).';\n              } else {\n                msg = 'This one didn\\'t generate \u2014 your credits have been returned. Please try again.';\n              }\n              slots[id].innerHTML = '<p style=\"color:#B91C1C;font-size:13px;margin:0;line-height:1.5;\">'+msg+'<\/p>';\n            }\n            maybeFinish();\n          }).catch(function(){});\n        });\n        if (tries >= max){ clearInterval(iv); maybeFinish(true); }\n      }, 3000);\n\n      function maybeFinish(timeout){\n        var allDone = taskIds.every(function(id){ return done[id]; });\n        if (allDone || timeout){\n          clearInterval(iv);\n          busy=false; btn.disabled=false; btn.textContent='Create again';\n        }\n      }\n    }\n  })();\n\n  \/* ===== Persistent footer CTAs (account \/ top up \/ view plans) ===== *\/\n  (function wireFooterCtas(){\n    var acc = el('ais-foot-account');\n    var top = el('ais-foot-topup');\n    var sub = el('ais-foot-subscribe');\n    if(!acc || !top || !sub) return;\n\n    \/\/ NOTE: these are intentionally NOT gated behind DEMO mode. A customer\n    \/\/ should be able to log in, view plans, or top up even before the\n    \/\/ ElevenLabs key is configured (DEMO only affects whether you can\n    \/\/ generate audio, not whether you can buy).\n\n    \/\/ Account link: always show. Logged-out = \"Log in\" (opens modal), logged-in = \"My account\".\n    if (CFG.accountUrl) {\n      acc.href = CFG.accountUrl;\n      el('ais-foot-account-text').textContent = CFG.loggedIn ? 'My account' : 'Log in';\n      acc.style.display = 'inline-flex';\n      if (!CFG.loggedIn && window.RJ24OpenLogin) {\n        acc.addEventListener('click', function(e){ e.preventDefault(); window.RJ24OpenLogin('login'); });\n      }\n    }\n    \/\/ Top up: ONLY when logged in. Shows \"Top up 100 credits \u2014 \u00a339\" from settings.\n    if (CFG.topUpUrl && CFG.loggedIn) {\n      top.href = CFG.topUpUrl;\n      if (CFG.topUpLabel) { top.textContent = CFG.topUpLabel; }\n      top.style.display = 'inline-flex';\n    } else {\n      top.style.display = 'none';\n    }\n    \/\/ View plans: ALWAYS show. Use configured URL, else fall back to \/shop\/ so\n    \/\/ the button is never missing (a logged-out visitor must be able to find pricing).\n    var plansUrl = CFG.subscribeUrl || (CFG.siteUrl ? (CFG.siteUrl.replace(\/\\\/$\/,'') + '\/shop\/') : '\/shop\/');\n    sub.href = plansUrl;\n    sub.style.display = 'inline-flex';\n    if (!CFG.loggedIn && window.RJ24OpenLogin) {\n      sub.addEventListener('click', function(e){ e.preventDefault(); window.RJ24OpenLogin('register'); });\n    }\n    if (!CFG.loggedIn || (CFG.credits||0) < 1) {\n      sub.style.boxShadow = '0 6px 18px rgba(236,72,153,.32)';\n    }\n  })();\n\n  \/\/ Sync any restored form fields into JS state (fixes wrong cost after refresh).\n  setTimeout(function(){ if (window.RJ24AISSyncRestored) window.RJ24AISSyncRestored(); }, 0);\n\n  \/* ===== Adaptive layout: logged-in regulars get the slim workspace; the full\n     sell section (hero, disclaimer, demos) folds away behind a toggle. Logged-out\n     visitors always see the full pitch. ===== *\/\n  (function adaptiveLayout(){\n    var sell = el('ais-sell'), slim = el('ais-slim'), toggle = el('ais-slim-toggle');\n    if (!sell) return;\n    if (CFG.loggedIn) {\n      sell.style.display = 'none';        \/\/ fold the pitch away for regulars\n      if (slim) slim.style.display = 'flex';\n      var open = false;\n      if (toggle) toggle.addEventListener('click', function(){\n        open = !open;\n        sell.style.display = open ? 'block' : 'none';\n        toggle.textContent = open ? '\u2715 Hide' : '\u2139 What\\u2019s this & hear demos';\n        if (open) sell.scrollIntoView({behavior:'smooth', block:'start'});\n      });\n    }\n    \/\/ else: logged out \u2014 leave the full sell section visible, slim hidden.\n  })();\n\n  \/* ===================== v1.6.1: Server-side creation storage ===================== *\/\n  \/* Two responsibilities:\n     1. saveCreation(blobUrl, metadata) \u2014 fired after each successful Advert\/\n        Sweeper\/DJ-Drop\/Podcast create. Fetches the WAV blob, encodes to MP3\n        via lamejs, POSTs base64 to \/save. Non-blocking \u2014 UI shows pill status.\n     2. loadCreations() \u2014 when My Creations tab opens, GETs \/creations, renders\n        cards with audio + download + delete.\n\n     Save flow is graceful: if lamejs hasn't loaded yet, we wait briefly. If\n     the upload fails, we show \"save failed\" pill but don't break the create\n     experience \u2014 customer still has their audio loaded in the result panel.\n  *\/\n  (function creationsModule(){\n    var CREATIONS_ENABLED = CFG.creationsEnabled !== false;\n\n    \/* Wait for lamejs to be available. It's loaded async, so usually present\n       by the time the user finishes their first create. Polls every 200ms up\n       to 5 seconds, then gives up. *\/\n    function waitForLame(callback){\n      if (window.lamejs && window.lamejs.Mp3Encoder) { callback(true); return; }\n      var tries = 0;\n      var iv = setInterval(function(){\n        tries++;\n        if (window.lamejs && window.lamejs.Mp3Encoder) {\n          clearInterval(iv); callback(true);\n        } else if (tries > 25) { \/\/ 5 sec\n          clearInterval(iv); callback(false);\n        }\n      }, 200);\n    }\n\n    \/* Decode a WAV blob URL into AudioBuffer, then encode to MP3 via lamejs.\n       Returns base64 string. *\/\n    function wavBlobToMp3Base64(blobUrl){\n      return fetch(blobUrl).then(function(r){ return r.arrayBuffer(); }).then(function(buf){\n        \/\/ Decode WAV header to extract PCM data + sample rate + channels.\n        \/\/ We only support the format we generate (16-bit PCM, 44100Hz).\n        var view = new DataView(buf);\n        \/\/ RIFF header: bytes 0-3 = \"RIFF\", 8-11 = \"WAVE\"\n        var sampleRate = view.getUint32(24, true);\n        var channels = view.getUint16(22, true);\n        \/\/ Find the 'data' chunk\n        var offset = 12;\n        var dataOffset = -1, dataSize = 0;\n        while (offset < buf.byteLength - 8) {\n          var chunkId = String.fromCharCode(\n            view.getUint8(offset), view.getUint8(offset+1),\n            view.getUint8(offset+2), view.getUint8(offset+3)\n          );\n          var chunkSize = view.getUint32(offset+4, true);\n          if (chunkId === 'data') {\n            dataOffset = offset + 8;\n            dataSize = chunkSize;\n            break;\n          }\n          offset += 8 + chunkSize;\n        }\n        if (dataOffset < 0) throw new Error('No data chunk in WAV');\n\n        \/\/ Extract PCM samples as Int16Array\n        var samples = new Int16Array(buf, dataOffset, dataSize \/ 2);\n\n        \/\/ Split into L\/R if stereo\n        var left, right;\n        if (channels === 2) {\n          var frames = samples.length \/ 2;\n          left = new Int16Array(frames);\n          right = new Int16Array(frames);\n          for (var i = 0; i < frames; i++) {\n            left[i] = samples[i * 2];\n            right[i] = samples[i * 2 + 1];\n          }\n        } else {\n          left = samples;\n        }\n\n        \/\/ Encode to MP3 at 192 kbps\n        var mp3enc = new window.lamejs.Mp3Encoder(channels, sampleRate, 192);\n        var mp3Data = [];\n        var blockSize = 1152;\n        for (var j = 0; j < left.length; j += blockSize) {\n          var lChunk = left.subarray(j, j + blockSize);\n          var rChunk = channels === 2 ? right.subarray(j, j + blockSize) : null;\n          var mp3buf = channels === 2\n            ? mp3enc.encodeBuffer(lChunk, rChunk)\n            : mp3enc.encodeBuffer(lChunk);\n          if (mp3buf.length > 0) mp3Data.push(mp3buf);\n        }\n        var tail = mp3enc.flush();\n        if (tail.length > 0) mp3Data.push(tail);\n\n        \/\/ Concat all MP3 chunks into one Uint8Array\n        var totalLen = mp3Data.reduce(function(s, c){ return s + c.length; }, 0);\n        var merged = new Uint8Array(totalLen);\n        var pos = 0;\n        for (var k = 0; k < mp3Data.length; k++) {\n          merged.set(mp3Data[k], pos);\n          pos += mp3Data[k].length;\n        }\n\n        \/\/ Base64-encode for JSON transmission. We chunk to avoid stack overflow\n        \/\/ on very large arrays (String.fromCharCode.apply(...) chokes on >100k args).\n        var chunkSize2 = 32768;\n        var binary = '';\n        for (var m = 0; m < merged.length; m += chunkSize2) {\n          binary += String.fromCharCode.apply(null, merged.subarray(m, m + chunkSize2));\n        }\n        return btoa(binary);\n      });\n    }\n\n    \/* Save a creation. Adds a status pill to the result panel showing\n       saving\/saved\/failed. Non-blocking \u2014 never throws to the caller. *\/\n    function saveCreation(audioUrl, meta, statusElId){\n      if (!CREATIONS_ENABLED) return;\n      if (!CFG.loggedIn || !CFG.saveUrl) return;\n      if (!audioUrl) return;\n\n      var pillEl = statusElId ? el(statusElId) : null;\n      function setStatus(state, text){\n        if (!pillEl) return;\n        pillEl.className = 'ais-save-pill ' + state;\n        pillEl.textContent = text;\n        pillEl.style.display = 'inline-flex';\n      }\n\n      setStatus('', '\u23f3 Saving to your account\u2026');\n\n      \/\/ v1.6.4: if it's a regular http(s) URL (sung jingles \u2014 kie.ai\/Suno CDN),\n      \/\/ skip the WAV decode + MP3 encode and let the server fetch the file\n      \/\/ directly. Only blob: URLs (browser-mixed audio) go through lamejs.\n      if (audioUrl.indexOf('http') === 0) {\n        var Hu = { 'Content-Type':'application\/json' };\n        if (CFG.nonce) Hu['X-WP-Nonce'] = CFG.nonce;\n        var payloadU = Object.assign({ audio_url: audioUrl }, meta || {});\n        fetch(CFG.saveUrl, {\n          method:'POST', credentials:'same-origin', headers:Hu,\n          body: JSON.stringify(payloadU)\n        }).then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); })\n          .then(function(out){\n            if (out.ok && out.j && out.j.ok) {\n              setStatus('saved', '\u2713 Saved to your My Creations tab (90 days)');\n            } else {\n              var msgU = (out.j && out.j.message) ? out.j.message : 'Save failed';\n              setStatus('failed', '\u26a0 ' + msgU);\n            }\n          }).catch(function(e){\n            setStatus('failed', '\u26a0 Save failed');\n            if (window.console && console.warn) console.warn('Creation save error:', e);\n          });\n        return;\n      }\n\n      waitForLame(function(loaded){\n        if (!loaded) {\n          setStatus('failed', '\u26a0 Could not save (encoder not loaded)');\n          return;\n        }\n        wavBlobToMp3Base64(audioUrl).then(function(b64){\n          var payload = Object.assign({ audio: b64 }, meta || {});\n          var H = { 'Content-Type':'application\/json' };\n          if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n          return fetch(CFG.saveUrl, {\n            method:'POST', credentials:'same-origin', headers:H,\n            body: JSON.stringify(payload)\n          }).then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); });\n        }).then(function(out){\n          if (out.ok && out.j && out.j.ok) {\n            setStatus('saved', '\u2713 Saved to your My Creations tab (90 days)');\n          } else {\n            var msg = (out.j && out.j.message) ? out.j.message : 'Save failed';\n            setStatus('failed', '\u26a0 ' + msg);\n          }\n        }).catch(function(e){\n          setStatus('failed', '\u26a0 Save failed');\n          \/* eslint-disable no-console *\/\n          if (window.console && console.warn) console.warn('Creation save error:', e);\n        });\n      });\n    }\n    window.RJ24SaveCreation = saveCreation;\n\n    \/* ---- My Creations list rendering ---- *\/\n    function fmtRelativeDate(ts){\n      if (!ts) return '';\n      var now = Math.floor(Date.now()\/1000);\n      var diff = now - ts;\n      if (diff < 60) return 'Just now';\n      if (diff < 3600) return Math.floor(diff\/60) + ' min ago';\n      if (diff < 86400) return Math.floor(diff\/3600) + 'h ago';\n      if (diff < 86400*7) return Math.floor(diff\/86400) + 'd ago';\n      var d = new Date(ts*1000);\n      return d.toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' });\n    }\n    function fmtExpiresIn(expiresAt){\n      if (!expiresAt) return '';\n      var now = Math.floor(Date.now()\/1000);\n      var days = Math.ceil((expiresAt - now) \/ 86400);\n      if (days < 0) return 'Expired';\n      if (days === 0) return 'Expires today';\n      if (days === 1) return 'Expires tomorrow';\n      return 'Expires in ' + days + ' days';\n    }\n    function toolLabel(t){\n      return ({\n        sweeper:'\ud83d\udce3 Sweeper', djdrop:'\ud83c\udfa7 DJ Drop', advert:'\ud83c\udf99\ufe0f Advert', podcast:'\ud83c\udfac Podcast', sung:'\ud83c\udfb5 Sung Jingle'\n      })[t] || t;\n    }\n    function escHtml(s){ var d=document.createElement('div'); d.textContent = String(s||''); return d.innerHTML; }\n\n    function renderCreations(creations, cap, retentionDays){\n      var body = el('creations-body');\n      if (!body) return;\n\n      \/\/ Header stats\n      var cl = el('creations-count-label'); if (cl) cl.textContent = creations.length;\n      var capl = el('creations-cap-label'); if (capl) capl.textContent = cap;\n      var rl = el('creations-retention-label'); if (rl) rl.textContent = retentionDays;\n      var meta = el('creations-meta');\n      if (meta) {\n        var pctFull = Math.round((creations.length \/ Math.max(1,cap)) * 100);\n        meta.innerHTML = '<b>' + creations.length + '\/' + cap + '<\/b><br>creations saved' +\n          (pctFull >= 90 ? '<br><span style=\"color:#B91C1C;\">Near cap \u2014 oldest auto-removed<\/span>' : '');\n      }\n\n      \/\/ Out-of-credits banner inside the tab\n      var banner = el('creations-no-credits-banner');\n      if (banner) {\n        if (CFG.loggedIn && (CFG.credits || 0) < 1 && !freeFirstAvailable()) {\n          banner.style.display = 'block';\n          var plansUrl = CFG.subscribeUrl || '\/shop\/';\n          var topUpUrl = CFG.topUpUrl || plansUrl;\n          banner.className = 'ais-creations-banner';\n          banner.innerHTML =\n            '<span>\ud83c\udfb5 <b>You\\'re out of credits.<\/b> All your saved creations stay here \u2014 but to make new ones, top up or pick a plan.<\/span>' +\n            '<a href=\"' + plansUrl + '\" class=\"rj-btn\">View plans<\/a>' +\n            '<a href=\"' + topUpUrl + '\" class=\"rj-btn rj-btn-secondary\">Top up<\/a>';\n        } else {\n          banner.style.display = 'none';\n          banner.innerHTML = '';\n        }\n      }\n\n      if (!CFG.loggedIn) {\n        body.innerHTML = '<div class=\"ais-creations-empty\"><div class=\"ico\">\ud83d\udd10<\/div><h3>Log in to see your creations<\/h3><p>Once you\\'re logged in and start making things, they\\'ll appear here for 90 days.<\/p><\/div>';\n        return;\n      }\n      if (!creations.length) {\n        body.innerHTML = '<div class=\"ais-creations-empty\"><div class=\"ico\">\ud83c\udfac<\/div><h3>No saved creations yet<\/h3><p>Anything you make in the Advert, Sweeper, DJ Drop or Podcast tabs is auto-saved here for ' + retentionDays + ' days.<\/p><\/div>';\n        return;\n      }\n\n      var html = '<div class=\"ais-creations-grid\">';\n      creations.forEach(function(c){\n        var expClass = '';\n        var daysLeft = Math.ceil(((c.expires_at||0) - Math.floor(Date.now()\/1000)) \/ 86400);\n        if (daysLeft < 14) expClass = 'warn';\n        var stingInfo = c.sting ? ((c.sting_genre||'sting') + (c.sting_tempo && c.sting_tempo !== 'normal' ? ' (' + c.sting_tempo + ')' : '')) : 'no music';\n        html +=\n          '<div class=\"ais-creation-card\" data-id=\"' + escHtml(c.id) + '\">' +\n            '<div class=\"ais-creation-top\">' +\n              '<span class=\"ais-creation-type\">' + toolLabel(c.tool) + '<\/span>' +\n              '<span class=\"ais-creation-date\">' + fmtRelativeDate(c.created_at) + '<\/span>' +\n            '<\/div>' +\n            '<div class=\"ais-creation-script ' + (c.script ? '' : 'empty') + '\">' +\n              (c.script ? escHtml(c.script) : '(no script)') +\n            '<\/div>' +\n            '<div class=\"ais-creation-meta\">' +\n              '<b>' + escHtml(c.voice_name||'') + '<\/b>' + (c.voice_country?' \u00b7 '+escHtml(c.voice_country):'') +\n              ' \u00b7 ' + escHtml(stingInfo) +\n              (c.duration ? ' \u00b7 ' + Math.round(c.duration*10)\/10 + 's' : '') +\n            '<\/div>' +\n            '<audio controls preload=\"none\" class=\"ais-creation-audio\" src=\"' + escHtml(c.file_url) + '\"><\/audio>' +\n            '<div class=\"ais-creation-expires ' + expClass + '\">' + fmtExpiresIn(c.expires_at) + '<\/div>' +\n            '<div class=\"ais-creation-actions\">' +\n              '<a class=\"rj-btn-mini\" href=\"' + escHtml(c.file_url) + '\" download=\"' + escHtml((c.tool||'creation') + '-' + c.id + '.mp3') + '\">\u2b07 Download<\/a>' +\n              '<button type=\"button\" class=\"rj-btn-mini rj-danger\" data-delete=\"' + escHtml(c.id) + '\">\ud83d\uddd1 Delete<\/button>' +\n            '<\/div>' +\n          '<\/div>';\n      });\n      html += '<\/div>';\n      body.innerHTML = html;\n\n      \/\/ Wire delete buttons\n      body.querySelectorAll('[data-delete]').forEach(function(btn){\n        btn.addEventListener('click', function(){\n          var id = btn.getAttribute('data-delete');\n          if (!id) return;\n          if (!confirm('Delete this creation permanently? Download it first if you want to keep a copy.')) return;\n          btn.disabled = true; btn.textContent = 'Deleting\u2026';\n          var H = {}; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n          fetch(CFG.creationDeleteUrl + '?id=' + encodeURIComponent(id), {\n            method:'DELETE', credentials:'same-origin', headers:H\n          }).then(function(r){ return r.json(); }).then(function(j){\n            if (j && j.ok) {\n              loadCreations(); \/\/ refresh\n            } else {\n              btn.disabled = false; btn.textContent = '\ud83d\uddd1 Delete';\n              toast((j && j.message) ? j.message : 'Could not delete.', 'error');\n            }\n          }).catch(function(){\n            btn.disabled = false; btn.textContent = '\ud83d\uddd1 Delete';\n            toast('Could not delete.', 'error');\n          });\n        });\n      });\n    }\n\n    function loadCreations(){\n      if (!CFG.creationsUrl) {\n        var body0 = el('creations-body');\n        if (body0) body0.innerHTML = '<div class=\"ais-creations-empty\"><h3>Creations are disabled<\/h3><p>Server-side saving isn\\'t enabled on this site.<\/p><\/div>';\n        return;\n      }\n      if (!CFG.loggedIn) {\n        renderCreations([], CFG.creationsMaxPerUser || 100, CFG.creationsRetentionDays || 90);\n        return;\n      }\n      var body = el('creations-body');\n      if (body) body.innerHTML = '<div class=\"ais-creations-loading\">Loading your creations\u2026<\/div>';\n\n      var H = {}; if (CFG.nonce) H['X-WP-Nonce'] = CFG.nonce;\n      fetch(CFG.creationsUrl, { credentials:'same-origin', headers:H })\n        .then(function(r){ return r.json(); })\n        .then(function(j){\n          var list = (j && j.creations) ? j.creations : [];\n          var cap = (j && j.cap) || (CFG.creationsMaxPerUser || 100);\n          var ret = (j && j.retentionDays) || (CFG.creationsRetentionDays || 90);\n          renderCreations(list, cap, ret);\n        })\n        .catch(function(){\n          if (body) body.innerHTML = '<div class=\"ais-creations-empty\"><h3>Could not load creations<\/h3><p>Network error \u2014 try again in a moment.<\/p><\/div>';\n        });\n    }\n    window.RJ24LoadCreations = loadCreations;\n  })();\n\n  \/* ===== Demo players ===== *\/\n  (function demos(){\n    var cur = null;\n    document.querySelectorAll('#rj-ai-studio .ais-demo').forEach(function(d){\n      var src = d.getAttribute('data-src') || '';\n      var btn = d.querySelector('.ais-demo-play');\n      if (!src) {\n        d.classList.add('is-empty');\n        var small = d.querySelector('small'); if (small) small.textContent = 'Coming soon';\n        if (btn) btn.textContent = '\u25b6';\n        return;\n      }\n      var audio = new Audio(src);\n      audio.addEventListener('ended', function(){ btn.textContent = '\u25b6'; });\n      btn.addEventListener('click', function(){\n        if (cur && cur !== audio){ cur.pause(); cur.currentTime = 0; document.querySelectorAll('#rj-ai-studio .ais-demo-play').forEach(function(b){ b.textContent='\u25b6'; }); }\n        if (audio.paused){ audio.play(); btn.textContent = '\u275a\u275a'; cur = audio; }\n        else { audio.pause(); btn.textContent = '\u25b6'; }\n      });\n    });\n  })();\n\n\n  \/* =========================================================================\n     MIX EDITOR\n     Opened from a finished result. Renders through its own mixer so the normal\n     create path is untouched \u2014 see the header note in mix-editor.js.html.\n     ====================================================================== *\/\n  var MIXED = null;   \/\/ the one open editor, if any\n\n  function mixSecondsToX(t, px, total){ return (t \/ total) * px; }\n\n  \/* Peak pairs per pixel column, cached per buffer+width so dragging is cheap. *\/\n  var PEAK_CACHE = {};\n  \/* One fine-grained pass per buffer, kept for as long as the buffer is around.\n     256 samples a bucket is about 5.8ms at 44.1k - finer than a screen pixel at\n     any zoom the editor offers, so every window can be re-bucketed from this\n     instead of rescanning a couple of million samples on every frame. *\/\n  var PEAK_STEP = 256;\n  function basePeaks(buf, key){\n    if (PEAK_CACHE[key]) return PEAK_CACHE[key];\n    var ch = buf.getChannelData(0), n = ch.length;\n    var cols = Math.max(1, Math.ceil(n \/ PEAK_STEP));\n    var mn = new Float32Array(cols), mx = new Float32Array(cols), rm = new Float32Array(cols);\n    for (var c = 0; c < cols; c++){\n      var s = c * PEAK_STEP, e = Math.min(n, s + PEAK_STEP);\n      var lo = 0, hi = 0, sum = 0, k = 0;\n      for (var i = s; i < e; i++){\n        var v = ch[i];\n        if (v < lo) lo = v;\n        if (v > hi) hi = v;\n        sum += v * v; k++;\n      }\n      mn[c] = lo; mx[c] = hi; rm[c] = k ? Math.sqrt(sum \/ k) : 0;\n    }\n    PEAK_CACHE[key] = { mn:mn, mx:mx, rm:rm, cols:cols, rate:buf.sampleRate \/ PEAK_STEP };\n    return PEAK_CACHE[key];\n  }\n\n  \/* Re-bucket the fine table down to exactly the columns being drawn, over\n     exactly the window being shown. *\/\n  function windowPeaks(buf, key, a, b, cols){\n    var base = basePeaks(buf, key);\n    var i0 = Math.max(0, Math.floor(a * base.rate));\n    var i1 = Math.min(base.cols, Math.max(i0 + 1, Math.ceil(b * base.rate)));\n    var span = i1 - i0;\n    var out = new Float32Array(cols * 3);\n    for (var c = 0; c < cols; c++){\n      var s = i0 + Math.floor(c * span \/ cols);\n      var e = i0 + Math.floor((c + 1) * span \/ cols);\n      if (e <= s) e = s + 1;\n      if (e > base.cols) e = base.cols;\n      var lo = 0, hi = 0, r = 0, k = 0;\n      for (var i = s; i < e; i++){\n        if (base.mn[i] < lo) lo = base.mn[i];\n        if (base.mx[i] > hi) hi = base.mx[i];\n        r += base.rm[i] * base.rm[i]; k++;\n      }\n      out[c*3] = lo; out[c*3+1] = hi; out[c*3+2] = k ? Math.sqrt(r \/ k) : 0;\n    }\n    return out;\n  }\n\n  \/* Gain shown at the top of a clip. Above 1.0 so a 1.0 line does not sit on\n     the very edge where its points cannot be grabbed. *\/\n  var ENV_MAX = 1.25;\n\n  function envAt(env, t){\n    if (!env || !env.length) return 1;\n    if (t <= env[0].t) return env[0].g;\n    for (var i = 1; i < env.length; i++){\n      if (t <= env[i].t){\n        var a = env[i-1], b = env[i], span = (b.t - a.t) || 1e-6;\n        return a.g + (b.g - a.g) * ((t - a.t) \/ span);\n      }\n    }\n    return env[env.length-1].g;\n  }\n\n  function lighten(hex, amt){\n    var n = parseInt(hex.slice(1), 16);\n    var r = (n >> 16) & 255, g2 = (n >> 8) & 255, b = n & 255;\n    r = Math.round(r + (255 - r) * amt);\n    g2 = Math.round(g2 + (255 - g2) * amt);\n    b = Math.round(b + (255 - b) * amt);\n    return 'rgb(' + r + ',' + g2 + ',' + b + ')';\n  }\n\n  function drawClip(cv, buf, key, trimA, trimB, env, colour){\n    var w = cv.clientWidth || 1, h = cv.clientHeight || 1;\n    var dpr = Math.min(2, window.devicePixelRatio || 1);\n    cv.width = Math.max(1, Math.floor(w * dpr));\n    cv.height = Math.max(1, Math.floor(h * dpr));\n    var g = cv.getContext('2d');\n    g.setTransform(dpr, 0, 0, dpr, 0, 0);\n    g.clearRect(0, 0, w, h);\n\n    \/* One column per DEVICE pixel, over exactly the window on screen. Drawing\n       in CSS pixels was throwing away half the resolution on a retina panel. *\/\n    var cols = Math.max(1, Math.floor(w * dpr));\n    var step = 1 \/ dpr;\n    var pk = windowPeaks(buf, key, trimA, trimB, cols);\n    var mid = h \/ 2, amp = h \/ 2 - 3;\n    var span0 = (trimB - trimA) || 1e-6;\n\n    \/* Shading for the held-back part goes down FIRST, behind the audio. Painted\n       over the top it hid the waveform completely on anything deeply ducked. *\/\n    if (env && env.length){\n      g.beginPath();\n      g.moveTo(0, 0);\n      for (var sx = 0; sx <= cols; sx++){\n        g.lineTo(sx * step, h * (1 - envAt(env, (sx \/ cols) * span0) \/ ENV_MAX));\n      }\n      g.lineTo(w, 0);\n      g.closePath();\n      g.fillStyle = 'rgba(6,8,12,.42)';\n      g.fill();\n    }\n\n    \/* peak envelope *\/\n    g.beginPath();\n    for (var x = 0; x < cols; x++){\n      var t = mid - pk[x*3+1] * amp;\n      if (x === 0) g.moveTo(x * step, t); else g.lineTo(x * step, t);\n    }\n    for (var x2 = cols - 1; x2 >= 0; x2--){\n      g.lineTo(x2 * step, mid - pk[x2*3] * amp);\n    }\n    g.closePath();\n    g.fillStyle = colour;\n    g.globalAlpha = 0.55;\n    g.fill();\n\n    \/* RMS core, the way Audition draws it - this is what makes it readable\n       rather than a silhouette *\/\n    g.beginPath();\n    for (var x3 = 0; x3 < cols; x3++){\n      var r = pk[x3*3+2] * amp;\n      if (x3 === 0) g.moveTo(x3 * step, mid - r); else g.lineTo(x3 * step, mid - r);\n    }\n    for (var x4 = cols - 1; x4 >= 0; x4--){\n      g.lineTo(x4 * step, mid + pk[x4*3+2] * amp);\n    }\n    g.closePath();\n    g.fillStyle = lighten(colour, 0.25);\n    g.globalAlpha = 1;\n    g.fill();\n\n    \/* centre line, so silence still reads as audio rather than as nothing *\/\n    g.globalAlpha = 0.35;\n    g.fillStyle = colour;\n    g.fillRect(0, mid - 0.5 \/ dpr, w, 1 \/ dpr);\n    g.globalAlpha = 1;\n\n    \/* The envelope, drawn the way Audition draws it: a line across the clip,\n       with everything above it dimmed so you can see at a glance how much of the\n       signal is being held back. *\/\n    \/* The line itself sits on top, the way Audition draws it - over the audio,\n       never instead of it. A dark under-stroke keeps it readable against a\n       bright waveform. *\/\n    if (env && env.length){\n      g.beginPath();\n      for (var lx = 0; lx <= cols; lx++){\n        var ly = h * (1 - envAt(env, (lx \/ cols) * span0) \/ ENV_MAX);\n        if (lx === 0) g.moveTo(lx * step, ly); else g.lineTo(lx * step, ly);\n      }\n      g.strokeStyle = 'rgba(0,0,0,.55)'; g.lineWidth = 3.2; g.stroke();\n      g.strokeStyle = '#FBBF24'; g.lineWidth = 1.6; g.stroke();\n    }\n  }\n\n  \/* ---- the editor's own mixer -------------------------------------------- *\/\n  \/* Schedule one clip's envelope onto a gain node.\n     `base` is the context time that timeline zero maps to; `fromT` is where\n     playback is starting, so a clip already half gone starts half way up its\n     own envelope instead of at the beginning of it. *\/\n  function envSchedule(gain, p, base, fromT, glide){\n    var len = p.trimB - p.trimA;\n    var env = (p.env && p.env.length > 1) ? p.env : [{ t:0, g:1 }, { t:len, g:1 }];\n    var startAbs = Math.max(p.start, fromT);\n    var at = Math.max(0, base + startAbs);\n    var v0 = Math.max(0.0001, envAt(env, startAbs - p.start));\n    if (glide > 0){\n      \/* mid-flight change: ease from wherever it is now, or it clicks *\/\n      gain.gain.setValueAtTime(gain.gain.value, at);\n      gain.gain.linearRampToValueAtTime(v0, at + glide);\n    } else {\n      gain.gain.setValueAtTime(v0, at);\n    }\n    for (var i = 0; i < env.length; i++){\n      var abs = p.start + env[i].t;\n      if (abs <= startAbs + (glide || 0) + 0.001) continue;\n      gain.gain.linearRampToValueAtTime(Math.max(0.0001, env[i].g), Math.max(0.0001, base + abs));\n    }\n  }\n\n  \/* The signal path, built once and used by both the offline render and live\n     playback. Two copies of this would drift exactly the way the timings did,\n     and then what you hear while editing is not what you download. *\/\n  function mixGraph(ctx, parts, base, fromT){\n    var master = ctx.createGain(); master.gain.value = 0.85;\n    var lim = ctx.createDynamicsCompressor();\n    lim.threshold.value = -3; lim.knee.value = 0; lim.ratio.value = 20;\n    lim.attack.value = 0.002; lim.release.value = 0.18;\n    master.connect(lim).connect(ctx.destination);\n\n    var musicBus = ctx.createGain(); musicBus.gain.value = 1.0;\n    var musicComp = ctx.createDynamicsCompressor();\n    musicComp.threshold.value = -15.0; musicComp.knee.value = 6.0; musicComp.ratio.value = 3.0;\n    musicComp.attack.value = 0.010; musicComp.release.value = 0.25;\n    var mSplit = ctx.createChannelSplitter(2), mMerge = ctx.createChannelMerger(2);\n    var mL = ctx.createGain(), mR = ctx.createGain();\n    mL.gain.value = 1.35; mR.gain.value = 1.35;\n    musicBus.connect(musicComp);\n    musicComp.connect(mSplit);\n    mSplit.connect(mL, 0); mSplit.connect(mR, 1);\n    mL.connect(mMerge, 0, 0); mR.connect(mMerge, 0, 1);\n    mMerge.connect(master);\n\n    var nodes = [];\n    parts.forEach(function(p){\n      if (!p.buf) return;\n      var len = p.trimB - p.trimA, t0 = p.start, t1 = t0 + len;\n      if (t1 <= fromT + 0.001) return;            \/\/ already finished at this point\n      var src = ctx.createBufferSource(); src.buffer = p.buf;\n      var g = ctx.createGain();\n\n      if (p.id === 'voice'){\n        var hpf = ctx.createBiquadFilter(); hpf.type = 'highpass'; hpf.frequency.value = 90;\n        var body = ctx.createBiquadFilter(); body.type='peaking'; body.frequency.value=220; body.Q.value=0.7; body.gain.value=2;\n        var pres = ctx.createBiquadFilter(); pres.type='peaking'; pres.frequency.value=4200; pres.Q.value=0.8; pres.gain.value=3;\n        var comp = ctx.createDynamicsCompressor();\n        comp.threshold.value=-20; comp.knee.value=6; comp.ratio.value=4; comp.attack.value=0.004; comp.release.value=0.15;\n        var mk = ctx.createGain(); mk.gain.value = 1.9;\n        src.connect(hpf).connect(body).connect(pres).connect(comp).connect(mk).connect(g).connect(master);\n      } else {\n        src.connect(g).connect(musicBus);\n      }\n\n      envSchedule(g, p, base, fromT, 0);\n\n      var skip = Math.max(0, fromT - t0);         \/\/ how much of this clip is already gone\n      var when = Math.max(0, base + Math.max(t0, fromT));\n      if (p.loop && p.buf.duration < len - 0.01){\n        var window = Math.max(0.01, p.buf.duration - p.trimA);\n        src.loop = true; src.loopStart = p.trimA; src.loopEnd = p.buf.duration;\n        src.start(when, p.trimA + (skip % window));\n        try { src.stop(Math.max(when + 0.01, base + t1)); } catch(e){}\n      } else {\n        var off = p.trimA + skip;\n        var dur = Math.min(len - skip, Math.max(0, p.buf.duration - off));\n        if (dur > 0.001) src.start(when, off, dur);\n      }\n      nodes.push({ p:p, g:g, src:src });\n    });\n    return { master:master, nodes:nodes, base:base };\n  }\n\n  function mixArrangement(parts, opts){\n    opts = opts || {};\n    var sr = 44100;\n    var total = 0;\n    parts.forEach(function(p){\n      if (!p.buf) return;\n      total = Math.max(total, p.start + (p.trimB - p.trimA));\n    });\n    total = Math.max(0.5, total + 0.05);\n    var ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(2, Math.ceil(sr * total), sr);\n    mixGraph(ctx, parts, 0, 0);\n    return ctx.startRendering().then(function(r){ return { buffer:r, url:bufferToWavUrl(r), total:total }; });\n  }\n\n  \/* ---- open it ----------------------------------------------------------- *\/\n  function openMixEditor(o){\n    var host = el(o.resultId); if (!host) return;\n    var id = o.resultId + '-mixeditor';\n    var prev = el(id); if (prev) prev.remove();\n\n    var parts = o.parts().filter(function(p){ return p.buf; });\n    if (!parts.length) { toast('Nothing to edit yet \u2014 create something first.'); return; }\n\n    var wrap = document.createElement('div');\n    wrap.className = 'ais-mix'; wrap.id = id;\n    wrap.innerHTML =\n      '<div class=\"ais-mix-head\">' +\n        '<span class=\"ais-mix-title\">&#127911; Seg Edit &mdash; fine-tune your jingle, free<\/span>' +\n        '<button type=\"button\" class=\"ais-mix-btn\" data-a=\"play\">&#9654; Play<\/button>' +\n        '<span class=\"ais-mix-time\">0:00 \/ 0:00<\/span>' +\n        '<button type=\"button\" class=\"ais-mix-btn\" data-a=\"reset\">Reset<\/button>' +\n        '<button type=\"button\" class=\"ais-mix-btn ais-mix-btn-primary\" data-a=\"use\">Use this mix<\/button>' +\n        '<button type=\"button\" class=\"ais-mix-btn\" data-a=\"close\">Close<\/button>' +\n      '<\/div>' +\n      '<div class=\"ais-mix-body\"><div class=\"ais-mix-ruler\"><\/div><\/div>' +\n      '<div class=\"ais-mix-levels\"><\/div>' +\n      '<div class=\"ais-mix-warn\" style=\"display:none;\"><\/div>' +\n      '<div class=\"ais-mix-hint\"><b>Drag a block<\/b> to move it &middot; <b>drag its edges<\/b> to trim &middot; ' +\n        '<b>click anywhere<\/b> to jump there, even while it is playing.<br>' +\n        'Blocks snap to each other\\u2019s edges as you drag. Every change is free &mdash; ' +\n        'the mixing happens in your browser.<\/div>';\n    host.appendChild(wrap);\n\n    var body = wrap.querySelector('.ais-mix-body');\n    var ruler = wrap.querySelector('.ais-mix-ruler');\n    var levels = wrap.querySelector('.ais-mix-levels');\n    var timeEl = wrap.querySelector('.ais-mix-time');\n    var warnEl = wrap.querySelector('.ais-mix-warn');\n    \/* Its nominal level, so an auto-duck can drop it by a share of that rather\n       than to some fixed number that would make a quiet element jump. *\/\n    parts.forEach(function(p){\n      var top = 0;\n      (p.env || []).forEach(function(q){ if (q.g > top) top = q.g; });\n      p.envBase = top || 1;\n    });\n    var original = parts.map(function(p){ return JSON.parse(JSON.stringify({\n      id:p.id, start:p.start, trimA:p.trimA, trimB:p.trimB, env:p.env })); });\n\n    var view = { total: 1, px: 1 };\n    var rendered = null, playing = false, playCtx = null, playAt = 0, raf = 0;\n    var live = null;   \/* the graph currently sounding, or null *\/\n    var frozen = 0, dragRaf = 0;   \/* scale held steady for the length of one gesture *\/\n    var envRaf = 0;\n\n    \/* What the mix actually runs to. span() adds headroom to drag into, which\n       is right for the ruler and wrong for the readout. *\/\n    function contentLen(){\n      var t = 0;\n      parts.forEach(function(p){ t = Math.max(t, p.start + (p.trimB - p.trimA)); });\n      return t;\n    }\n\n    function span(){\n      var t = 0.5;\n      parts.forEach(function(p){ t = Math.max(t, p.start + (p.trimB - p.trimA)); });\n      \/* 20% headroom past the content. Without somewhere to drag TO, moving the\n         last block always had to grow the timeline, and growing the timeline\n         shrinks every pixel - so the block crawled while the finger ran. *\/\n      return t + Math.max(0.4, t * 0.2);\n    }\n\n    function fmt(s){ var m = Math.floor(s\/60), x = Math.floor(s%60); return m + ':' + (x<10?'0':'') + x; }\n\n    \/* ---- draw ---- *\/\n    var laneEls = {};\n    function build(){\n      Array.prototype.slice.call(body.querySelectorAll('.ais-mix-lane')).forEach(function(n){ n.remove(); });\n      laneEls = {};\n      parts.forEach(function(p){\n        var lane = document.createElement('div');\n        lane.className = 'ais-mix-lane';\n        var clip = document.createElement('div');\n        clip.className = 'ais-mix-clip';\n        clip.setAttribute('data-part', p.id);\n        clip.innerHTML = '<canvas><\/canvas><div class=\"ais-mix-clip-name\">' + p.name + '<\/div>' +\n          '<div class=\"ais-mix-trim l\"><\/div><div class=\"ais-mix-trim r\"><\/div>';\n        lane.appendChild(clip);\n        body.appendChild(lane);\n        laneEls[p.id] = { lane:lane, clip:clip, cv:clip.querySelector('canvas') };\n        wireClip(p, laneEls[p.id]);\n        wireAddPoint(p, laneEls[p.id]);\n        buildPoints(p, laneEls[p.id]);\n      });\n      var ph = document.createElement('div'); ph.className = 'ais-mix-play';\n      body.appendChild(ph); laneEls.__ph = ph;\n      layout();\n    }\n\n    \/* Cheap path: reposition only. No canvas work, no rescale. This is what a\n       drag calls, so the block under your finger moves 1:1 with it. *\/\n    function place(){\n      parts.forEach(function(p){\n        var L = laneEls[p.id]; if (!L) return;\n        var wpx = Math.max(18, mixSecondsToX(p.trimB - p.trimA, view.px, view.total));\n        L.clip.style.left  = mixSecondsToX(p.start, view.px, view.total) + 'px';\n        L.clip.style.width = wpx + 'px';\n        sizeGrips(L, wpx);\n      });\n      flagOverlaps();\n      movePlayhead(playAt);\n    }\n\n    \/* Carve a duck into everything the voice overlaps. Called after a clip is\n       moved or trimmed - which side was dragged does not matter, only which one\n       is the speech. Clips whose envelope you have edited yourself are left\n       alone for good. *\/\n    var DUCK_RAMP = 0.18;\n    function autoDuck(){\n      var v = null;\n      parts.forEach(function(p){ if (p.id === 'voice' && p.buf) v = p; });\n      if (!v) return;\n      var vA = v.start, vB = v.start + (v.trimB - v.trimA);\n      parts.forEach(function(p){\n        if (p === v || !p.buf || p.envTouched) return;\n        var len = p.trimB - p.trimA, a = p.start, b = a + len;\n        var base = p.envBase || 1;\n        var oA = Math.max(a, vA), oB = Math.min(b, vB);\n        if (oB - oA < 0.15){                       \/\/ not under the voice any more\n          p.env = [{ t:0, g:base }, { t:len, g:base }];\n          return;\n        }\n        \/* a share of its own level, so a quiet element and a loud bed drop by\n           the same amount and neither jumps when the duck lands *\/\n        var duck = base * 0.34;\n        var s = oA - a, e = oB - a, pts = [];\n        pts.push({ t:0, g:(s <= 0.02 ? duck : base) });\n        if (s > DUCK_RAMP + 0.02) pts.push({ t:s - DUCK_RAMP, g:base });\n        if (s > 0.02) pts.push({ t:s, g:duck });\n        pts.push({ t:Math.min(len - 0.01, e), g:duck });\n        if (e < len - DUCK_RAMP - 0.02) pts.push({ t:e + DUCK_RAMP, g:base });\n        pts.push({ t:len, g:(e >= len - 0.02 ? duck : base) });\n        p.env = envSeal(pts);\n      });\n    }\n\n    \/* Trimming changes how long the clip is, so the envelope has to be squeezed\n       back inside it - otherwise points sit past the end where they cannot be\n       reached and silently keep affecting the render. *\/\n    function clampEnv(p){\n      var len = p.trimB - p.trimA;\n      p.env[0].t = 0;\n      p.env[p.env.length-1].t = len;\n      for (var i = 1; i < p.env.length - 1; i++){\n        p.env[i].t = Math.max(0.01, Math.min(len - 0.01, p.env[i].t));\n      }\n      for (var j = p.env.length - 2; j >= 1; j--){\n        if (p.env[j].t >= p.env[j+1].t) p.env.splice(j, 1);\n      }\n    }\n\n    \/* Keep the middle of every clip grabbable. At 20px a side, a 33px closer on\n       a phone was 40px of grip over 33px of clip - every touch hit a trim handle\n       and the block could be resized but never moved. *\/\n    function sizeGrips(L, wpx){\n      var g = Math.max(6, Math.min(20, wpx * 0.18));\n      var t = L.clip.querySelectorAll('.ais-mix-trim');\n      for (var i = 0; i < t.length; i++) t[i].style.width = g + 'px';\n    }\n\n    \/* One draggable div per envelope point. Rebuilt whenever the shape changes -\n       there are only ever a handful, so this is cheaper than diffing. *\/\n    function buildPoints(p, L){\n      var old = L.clip.querySelectorAll('.ais-mix-pt');\n      for (var k = 0; k < old.length; k++) old[k].remove();\n      var len = (p.trimB - p.trimA) || 1e-6;\n      p.env.forEach(function(pt, i){\n        var last = (i === 0 || i === p.env.length - 1);\n        var d = document.createElement('div');\n        d.className = 'ais-mix-pt' + (last ? ' end' : '');\n        d.style.left = (100 * pt.t \/ len) + '%';\n        d.style.top  = (100 * (1 - pt.g \/ ENV_MAX)) + '%';\n        L.clip.appendChild(d);\n        wirePoint(p, L, i, d, last);\n      });\n    }\n\n    function wirePoint(p, L, i, d, pinned){\n      var dragging = false;\n      d.addEventListener('pointerdown', function(e){\n        dragging = true; frozen = view.total;\n        d.classList.add('on');\n        try { d.setPointerCapture && d.setPointerCapture(e.pointerId); } catch(err){}\n        e.preventDefault(); e.stopPropagation();   \/* never also drag the clip *\/\n      });\n      function move(e){\n        if (!dragging) return;\n        var r = L.clip.getBoundingClientRect(), len = (p.trimB - p.trimA) || 1e-6;\n        var g = ENV_MAX * (1 - (e.clientY - r.top) \/ Math.max(1, r.height));\n        p.env[i].g = Math.max(0, Math.min(ENV_MAX, g));\n        p.envTouched = true;      \/* yours now - auto-duck will not overwrite it *\/\n        if (!pinned){\n          var t = ((e.clientX - r.left) \/ Math.max(1, r.width)) * len;\n          \/* stay between its neighbours so the line can never fold back on itself *\/\n          p.env[i].t = Math.max(p.env[i-1].t + 0.01, Math.min(p.env[i+1].t - 0.01, t));\n        }\n        d.style.left = (100 * p.env[i].t \/ len) + '%';\n        d.style.top  = (100 * (1 - p.env[i].g \/ ENV_MAX)) + '%';\n        if (envRaf) return;\n        envRaf = requestAnimationFrame(function(){ envRaf = 0; redrawOne(p); liveEnv(p); });\n      }\n      function up(){\n        if (!dragging) return;\n        dragging = false; frozen = 0; d.classList.remove('on');\n        if (envRaf){ cancelAnimationFrame(envRaf); envRaf = 0; }\n        redrawOne(p); liveEnv(p);\n      }\n      window.addEventListener('pointermove', move);\n      window.addEventListener('pointerup', up);\n      window.addEventListener('pointercancel', up);\n      d.addEventListener('dblclick', function(e){\n        e.preventDefault(); e.stopPropagation();\n        if (pinned) return;                      \/* the ends always exist *\/\n        p.envTouched = true;\n        p.env.splice(i, 1);\n        buildPoints(p, L); redrawOne(p); liveEnv(p);\n      });\n    }\n\n    \/* Double-click\/tap the clip itself to drop a point onto the line there. *\/\n    function wireAddPoint(p, L){\n      L.clip.addEventListener('dblclick', function(e){\n        if (e.target.classList.contains('ais-mix-pt')) return;\n        var r = L.clip.getBoundingClientRect(), len = (p.trimB - p.trimA) || 1e-6;\n        var t = Math.max(0.02, Math.min(len - 0.02, ((e.clientX - r.left) \/ Math.max(1, r.width)) * len));\n        var at = p.env.length - 1;\n        for (var i = 1; i < p.env.length; i++){ if (t < p.env[i].t){ at = i; break; } }\n        p.envTouched = true;\n        p.env.splice(at, 0, { t:t, g:envAt(p.env, t) });\n        buildPoints(p, L); redrawOne(p); liveEnv(p);\n        e.preventDefault();\n      });\n    }\n\n    function clipColour(id){\n      return id === 'voice' ? '#7FB6DC' : id === 'bed' ? '#B79BE8'\n           : id === 'mid' ? '#9FD8C4' : '#D9B978';\n    }\n\n    function redrawOne(p){\n      var L = laneEls[p.id]; if (!L) return;\n      drawClip(L.cv, p.buf, p.id, p.trimA, p.trimB, p.env, clipColour(p.id));\n    }\n\n    \/* Overlap is legal - a short segue is wanted - but a block buried under\n       another one is almost always an accident, so say so rather than leave it\n       to be discovered by ear. *\/\n    \/* Overlap is how imaging is built - the voice comes in over the opener's\n       tail, the closer lands under the last words. The only arrangement that is\n       actually broken is a piece completely buried inside another, where you\n       cannot hear it at all. Warn about that and nothing else. *\/\n    function flagOverlaps(){\n      var msgs = [];\n      parts.forEach(function(p){ var L = laneEls[p.id]; if (L) L.clip.classList.remove('overlap'); });\n      for (var i = 0; i < parts.length; i++){\n        for (var j = 0; j < parts.length; j++){\n          if (i === j) continue;\n          var a = parts[i], b = parts[j];\n          if (a.id === 'voice' || b.id === 'voice') continue;   \/* voice over a bed is the point *\/\n          var aEnd = a.start + (a.trimB - a.trimA), bEnd = b.start + (b.trimB - b.trimA);\n          \/* a buried inside b, with nothing of it sticking out either side *\/\n          if (a.start >= b.start - 0.02 && aEnd <= bEnd + 0.02 && (bEnd - b.start) > (aEnd - a.start) + 0.3){\n            if (laneEls[a.id]) laneEls[a.id].clip.classList.add('overlap');\n            msgs.push(a.name + ' is buried under ' + b.name + ' \\u2014 you will not hear it');\n          }\n        }\n      }\n      if (!warnEl) return;\n      warnEl.textContent = msgs.length ? '\\u26A0 ' + msgs.join(' \\u00B7 ') : '';\n      warnEl.style.display = msgs.length ? '' : 'none';\n    }\n\n    function layout(){\n      \/* Only ever grow mid-gesture. Recomputing the span on every move made the\n         ruler rescale under the finger, so nothing tracked the drag. *\/\n      var need = span();\n      view.total = frozen ? Math.max(frozen, need) : need;\n      view.px = Math.max(240, body.clientWidth - 20);\n      \/\/ ruler\n      ruler.innerHTML = '';\n      var stepChoices = [1,2,5,10,15,30,60], stepIdx = 0;\n      while (stepIdx < stepChoices.length-1 && view.total\/stepChoices[stepIdx] > 12) stepIdx++;\n      var step = stepChoices[stepIdx];\n      for (var t = 0; t <= view.total; t += step){\n        var d = document.createElement('div'); d.className = 'ais-mix-tick';\n        d.style.left = mixSecondsToX(t, view.px, view.total) + 'px';\n        d.innerHTML = '<span>' + fmt(t) + '<\/span>';\n        ruler.appendChild(d);\n      }\n      parts.forEach(function(p){\n        var L = laneEls[p.id]; if (!L) return;\n        var wpx = Math.max(18, mixSecondsToX(p.trimB - p.trimA, view.px, view.total));\n        L.clip.style.left  = mixSecondsToX(p.start, view.px, view.total) + 'px';\n        L.clip.style.width = wpx + 'px';\n        sizeGrips(L, wpx);\n        redrawOne(p);\n      });\n      flagOverlaps();\n      movePlayhead(playAt);\n      timeEl.textContent = fmt(playAt) + ' \/ ' + fmt(contentLen());\n    }\n\n    function movePlayhead(t){\n      playAt = Math.max(0, Math.min(view.total, t));\n      if (laneEls.__ph) laneEls.__ph.style.left = (10 + mixSecondsToX(playAt, view.px, view.total)) + 'px';\n      timeEl.textContent = fmt(playAt) + ' \/ ' + fmt(contentLen());\n    }\n\n    \/* ---- dragging: one pointer path for mouse, pen and touch ---- *\/\n    function wireClip(p, L){\n      var mode = null, x0 = 0, base = 0, moved = false, downX = 0;\n      function secPerPx(){ return view.total \/ view.px; }\n      function down(e){\n        var t = e.target;\n        if (t.classList.contains('ais-mix-pt')) return;     \/* the point owns this one *\/\n        if (t.classList.contains('ais-mix-trim')) mode = t.classList.contains('l') ? 'trimL' : 'trimR';\n        else mode = 'move';\n        x0 = e.clientX;\n        base = mode === 'move' ? p.start\n             : mode === 'trimL' ? p.trimA : p.trimB;\n        L.clip.classList.add('dragging');\n        moved = false; downX = e.clientX;\n        frozen = view.total;\n        \/* Nice to have - it stops hover states and scroll chaining - but the\n           drag no longer depends on it, so a failure must not escape. *\/\n        try { L.clip.setPointerCapture && L.clip.setPointerCapture(e.pointerId); } catch (err) {}\n        e.preventDefault();\n      }\n      function move(e){\n        if (!mode) return;\n        if (Math.abs(e.clientX - downX) > 4) moved = true;\n        var d = (e.clientX - x0) * secPerPx();\n        var len = p.trimB - p.trimA;\n        if (mode === 'move'){\n          var want = Math.max(0, base + d);\n          \/* Clamped to the span we froze at pointerdown, so the scale cannot\n             change while something is being held and the drag stays 1:1. *\/\n          if (frozen) want = Math.min(want, Math.max(0, frozen - (p.trimB - p.trimA)));\n          \/* Magnetic edges. Come within a third of a second of another piece's\n             start or end and it clicks to it, so butting things up is the easy\n             thing to do rather than something you have to be precise about -\n             which matters most with a thumb. *\/\n          var SNAP = view.total \/ view.px * 12;    \/\/ ~12px, so it scales with zoom\n          var mine = p.trimB - p.trimA, best = null, bestD = SNAP;\n          parts.forEach(function(q){\n            if (q === p || !q.buf) return;\n            var qs = q.start, qe = q.start + (q.trimB - q.trimA);\n            [[qe, qe], [qs, qs], [qs - mine, qs - mine], [qe - mine, qe - mine]].forEach(function(c, i){\n              var target = c[0];\n              if (target < 0) return;\n              var dist = Math.abs(want - target);\n              if (dist < bestD){ bestD = dist; best = target; }\n            });\n          });\n          if (Math.abs(want) < SNAP) { best = 0; }      \/\/ and to the very start\n          p.start = (best === null) ? want : best;\n        }\n        else if (mode === 'trimL') p.trimA = Math.min(p.trimB - 0.15, Math.max(0, base + d));\n        else if (mode === 'trimR') p.trimB = Math.max(p.trimA + 0.15, Math.min(p.buf.duration, base + d));\n        if (mode !== 'move') clampEnv(p);\n\n        \/* Coalesce onto a frame. A fast drag fires pointermove far more often\n           than the screen refreshes, and each one used to cost four canvas\n           redraws. *\/\n        if (dragRaf) return;\n        dragRaf = requestAnimationFrame(function(){\n          dragRaf = 0;\n          \/* Never layout() mid-gesture - that is what used to rescale under the\n             finger. Moving needs no canvas work at all; trimming and fading\n             redraw exactly one clip. *\/\n          if (mode === 'move') place();\n          else { place(); redrawOne(p); }\n        });\n      }\n      function up(e){\n        if (!mode) return;\n        mode = null; L.clip.classList.remove('dragging');\n        if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = 0; }\n        frozen = 0;\n        if (!moved){\n          \/* pressed, not dragged - treat it as \"play from here\" *\/\n          layout();\n          seek(xToTime(e.clientX));\n          return;\n        }\n        autoDuck();\n        parts.forEach(function(q){ var LL = laneEls[q.id]; if (LL) buildPoints(q, LL); });\n        layout();      \/* settle back to a scale that fits *\/\n        liveRebuild();\n      }\n      L.clip.addEventListener('pointerdown', down);\n      \/* move\/up live on the window, not the clip. A drag routinely leaves the\n         block it started on - especially a short one on a phone - and binding\n         them to the clip meant the gesture died there, stuck mid-drag. *\/\n      window.addEventListener('pointermove', move);\n      window.addEventListener('pointerup', up);\n      window.addEventListener('pointercancel', up);\n      window.addEventListener('blur', up);\n    }\n\n    function xToTime(clientX){\n      var r = body.getBoundingClientRect();\n      return ((clientX - r.left - 10) \/ Math.max(1, view.px)) * view.total;\n    }\n    ruler.addEventListener('pointerdown', function(e){ seek(xToTime(e.clientX)); });\n    \/* Click anywhere in the lanes to jump there, playing or not. Clicks that\n       land on a clip are handled in wireClip, which can tell a click from a drag. *\/\n    body.addEventListener('pointerdown', function(e){\n      if (e.target.closest('.ais-mix-clip')) return;\n      if (e.target === ruler || ruler.contains(e.target)) return;\n      seek(xToTime(e.clientX));\n    });\n\n    \/* ---- levels ---- *\/\n    \/* No level sliders any more - a slider can only say \"how loud overall\",\n       and the thing that actually needed saying was \"how loud HERE\". That is\n       what the envelope is for. *\/\n    function buildLevels(){\n      levels.innerHTML = '';\n      levels.className = 'ais-mix-envhint';\n      levels.innerHTML = '<b>Volume:<\/b> drag the yellow line\\u2019s points up or down. ' +\n        '<b>Double-tap<\/b> a clip to add a point, or a point to remove it. ' +\n        'Slide music under the voice and it ducks itself \\u2014 until you move a point, after which that clip is yours. ' +\n        'Two points at the end, the last one dragged to the bottom, is a fade-out.';\n      if (1) return;\n      var bed = parts.filter(function(p){ return p.id === 'bed'; })[0];\n      function slider(label, get, set, min, max, stepv, unit){\n        var box = document.createElement('label'); box.className = 'ais-mix-lv';\n        box.innerHTML = '<b>' + label + '<\/b>';\n        var inp = document.createElement('input');\n        inp.type = 'range'; inp.min = min; inp.max = max; inp.step = stepv; inp.value = get();\n        var out = document.createElement('output'); out.textContent = Math.round(get()*100) + unit;\n        inp.addEventListener('input', function(){ set(+inp.value); out.textContent = Math.round(+inp.value*100) + unit; });\n        inp.addEventListener('change', render);\n        box.appendChild(inp); box.appendChild(out); levels.appendChild(box);\n      }\n      if (bed){\n        slider('Music level', function(){ return bed.level; }, function(v){ bed.level = v; }, 0, 1, 0.01, '%');\n        slider('Under the voice', function(){ return bed.duck; }, function(v){ bed.duck = v; }, 0, 0.6, 0.01, '%');\n      }\n      var v = parts.filter(function(p){ return p.id === 'voice'; })[0];\n      if (v) slider('Voice level', function(){ return v.level; }, function(x){ v.level = x; }, 0.4, 1.4, 0.02, '%');\n    }\n\n    \/* ---- render + play ---- *\/\n    var renderSeq = 0;\n    \/* Only for \"Use this mix\" and the download now. It used to run on every\n       gesture - a whole-mix offline render per drag - which was most of the cost\n       of the editor and none of the benefit, since playback is live. *\/\n    function render(){\n      var seq = ++renderSeq;\n      return mixArrangement(parts).then(function(r){\n        if (seq !== renderSeq) return null;\n        rendered = r;\n        return r;\n      }).catch(function(){ toast('Could not build that mix - try nudging it back.', 'error'); });\n    }\n\n    function stop(){\n      playing = false;\n      if (live){\n        live.nodes.forEach(function(n){ try { n.src.stop(); } catch(e){} });\n        try { live.master.disconnect(); } catch(e){}\n        live = null;\n      }\n      if (raf) cancelAnimationFrame(raf);\n      var b = wrap.querySelector('[data-a=\"play\"]');\n      b.innerHTML = '&#9654; Play'; b.classList.remove('on');\n    }\n\n    function start(from){\n      playCtx = playCtx || new (window.AudioContext || window.webkitAudioContext)();\n      if (live){\n        live.nodes.forEach(function(n){ try { n.src.stop(); } catch(e){} });\n        try { live.master.disconnect(); } catch(e){}\n        live = null;\n      }\n      var end = contentLen();\n      var at = (typeof from === 'number') ? from : playAt;\n      if (at >= end - 0.05) at = 0;\n      playing = true;\n      var b = wrap.querySelector('[data-a=\"play\"]');\n      b.innerHTML = '&#10073;&#10073; Pause'; b.classList.add('on');\n\n      \/* Build the graph only once the clock is genuinely running. resume() is\n         async, and on a suspended context currentTime is still 0 until it\n         resolves - anchoring the whole timeline to zero and scheduling\n         everything in the past. *\/\n      function go(){\n        if (!playing) return;                       \/\/ stopped while we waited\n        live = mixGraph(playCtx, parts, playCtx.currentTime + 0.06 - at, at);\n        (function tick(){\n          if (!playing || !live) return;\n          var t = playCtx.currentTime - live.base;\n          if (t >= contentLen()){ stop(); movePlayhead(0); return; }\n          movePlayhead(t);\n          raf = requestAnimationFrame(tick);\n        })();\n      }\n      if (playCtx.state === 'suspended'){\n        var p = playCtx.resume();\n        if (p && p.then) { p.then(go, go); } else { go(); }\n      } else {\n        go();\n      }\n    }\n\n    \/* Jump to a point. Mid-playback this rebuilds the graph from there, which is\n       microseconds - the old version had to re-render the whole mix first. *\/\n    function seek(t){\n      t = Math.max(0, Math.min(contentLen(), t));\n      movePlayhead(t);\n      if (playing) start(t);\n    }\n\n    \/* An envelope moved while it is playing: re-schedule just that clip's gain\n       from the playhead onward, so the change is audible immediately. *\/\n    function liveEnv(p){\n      if (!playing || !live) return;\n      var node = null;\n      live.nodes.forEach(function(n){ if (n.p === p) node = n; });\n      if (!node) return;\n      var now = playCtx.currentTime;\n      node.g.gain.cancelScheduledValues(now);\n      envSchedule(node.g, p, live.base, now - live.base, 0.02);\n    }\n\n    \/* A clip moved or was trimmed while playing - the sources themselves have to\n       be re-scheduled, so rebuild from where we are. Still no render. *\/\n    function liveRebuild(){\n      if (playing) start(playCtx.currentTime - live.base);\n    }\n\n    wrap.addEventListener('click', function(e){\n      var b = e.target.closest('[data-a]'); if (!b) return;\n      var a = b.getAttribute('data-a');\n      if (a === 'play'){ playing ? stop() : start(); }\n      else if (a === 'reset'){\n        stop();\n        original.forEach(function(o2, i){\n          var p = parts[i];\n          p.start=o2.start; p.trimA=o2.trimA; p.trimB=o2.trimB;\n          p.env = JSON.parse(JSON.stringify(o2.env));\n          p.envTouched = false;\n        });\n        build(); toast('Back to the original mix.');\n      }\n      else if (a === 'use'){\n        stop();\n        render().then(function(r){\n          if (!r) return;\n          var au = el(o.audioId); if (au) au.src = r.url;\n          var dl = el(o.dlId); if (dl) dl.href = r.url;\n          if (o.onUse) o.onUse(r.url);\n          toast('Saved to the player above. Download it from there \u2014 no credits used.');\n        });\n      }\n      else if (a === 'close'){ stop(); wrap.remove(); MIXED = null; }\n    });\n\n    window.addEventListener('resize', function(){ if (el(id)) layout(); });\n\n    \/* No render here. Playback is live, and the only thing that still needs\n       an offline render is \"Use this mix\", which does its own. *\/\n    build(); buildLevels();\n    MIXED = wrap;\n    wrap.scrollIntoView({ behavior:'smooth', block:'center' });\n  }\n\n  \/* Adds the Seg Edit button to a result panel, with a line underneath saying\n     what it is for - \"Adjust the mix\" described a slider, not a timeline. *\/\n  function attachMixEditorButton(o){\n    var host = el(o.resultId); if (!host) return;\n    var bid = o.resultId + '-mixbtn';\n    if (el(bid)) { el(bid).remove(); }\n    var wrap = document.createElement('div');\n    wrap.id = bid; wrap.className = 'ais-segedit-cta';\n    var b = document.createElement('button');\n    b.type = 'button';\n    b.className = 'ais-dl-btn ais-dl-secondary';\n    b.style.cssText = 'box-shadow:none;';\n    b.innerHTML = '&#127911; Open Seg Edit';\n    b.addEventListener('click', function(){ openMixEditor(o); });\n    wrap.appendChild(b);\n    var note = document.createElement('div');\n    note.className = 'ais-segedit-note';\n    note.innerHTML = 'Fine-tune it yourself &mdash; slide the music off your voice, trim the ending, ' +\n      'set the fades. <b>Free, as often as you like.<\/b>';\n    wrap.appendChild(note);\n    host.appendChild(wrap);\n  }\n\n  \/* ---------------------------------------------------------------------\n     Folding steps. The tools run 3.3 to 4.5 screens tall, most of it a\n     browsable library sitting at full height in the middle of a form, so\n     changing one piece meant scrolling past everything twice. Each step can\n     now fold to a single line that says what you picked, and they fold\n     themselves the moment a finished piece appears - which is when the steps\n     stop being what you are looking at.\n     --------------------------------------------------------------------- *\/\n  function aisFoldSteps(panelId, prefix){\n    var panel = document.getElementById(panelId);\n    if (!panel || panel.__folded) return;\n    panel.__folded = true;\n    var KEY = 'rj24_steps_' + prefix;\n    var open = {};\n    try { open = JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch(e){ open = {}; }\n\n    var boxes = [];\n    Array.prototype.slice.call(panel.children).forEach(function(box){\n      var head = box.querySelector('.ais-step-h');\n      if (!head || head.parentNode !== box) return;\n      box.classList.add('ais-stepbox');\n      var n = String(boxes.length);\n\n      var sum = document.createElement('span'); sum.className = 'ais-step-sum';\n      var caret = document.createElement('span'); caret.className = 'ais-step-caret';\n      head.appendChild(sum); head.appendChild(caret);\n\n      function summarise(){\n        \/* what did they choose in this step? read it off the UI rather than\n           reaching into any tool's state - there are four of them and they\n           do not share one *\/\n        var picked = [];\n        Array.prototype.slice.call(box.querySelectorAll('.ais-arr-slot.filled')).forEach(function(s){\n          var t = (s.textContent || '').replace(\/\\s+\/g, ' ').replace(\/^[^A-Za-z0-9]+\/, '').trim();\n          t = t.replace(\/^(Opener|Middle|Bed|Closer)\\s*\/i, '').replace(\/\\s*\\d+(\\.\\d+)?s.*$\/, '').trim();\n          if (t && t.indexOf('Nothing yet') === -1) picked.push(t);\n        });\n        var v = box.querySelector('.rj24-vp-v2-card.selected .rj24-vp-v2-card-name');\n        if (v) picked.push(v.textContent.trim());\n        var ta = box.querySelector('textarea');\n        if (!picked.length && ta && ta.value.trim()){\n          picked.push('\"' + ta.value.trim().slice(0, 40) + (ta.value.trim().length > 40 ? '\u2026' : '') + '\"');\n        }\n        sum.textContent = picked.length ? '\u2014 ' + picked.join(' \u00b7 ') : '';\n      }\n\n      function apply(){\n        var folded = box.classList.contains('folded');\n        caret.textContent = folded ? 'Change \u25be' : 'Hide \u25b4';\n        if (folded) summarise();\n      }\n      function toggle(){\n        box.classList.toggle('folded');\n        open[n] = !box.classList.contains('folded');\n        try { localStorage.setItem(KEY, JSON.stringify(open)); } catch(e){}\n        apply();\n      }\n      head.addEventListener('click', function(e){\n        if (e.target.closest('input,button,select,textarea,a,label')) return;\n        toggle();\n      });\n      if (open[n] === false) box.classList.add('folded');\n      apply();\n      boxes.push({ box:box, apply:apply, summarise:summarise });\n    });\n\n    \/* When a finished piece appears, fold the steps behind it. *\/\n    var res = document.getElementById(prefix + '-result-ready')\n           || document.getElementById('ais-result-ready');\n    if (res && window.MutationObserver){\n      var seen = false;\n      new MutationObserver(function(){\n        var on = res.offsetParent !== null;\n        if (on && !seen){\n          seen = true;\n          boxes.forEach(function(b){ b.box.classList.add('folded'); b.apply(); });\n        } else if (!on){ seen = false; }\n      }).observe(res, { attributes:true, attributeFilter:['style','class'] });\n    }\n  }\n\n  try {\n    aisFoldSteps('ais-advert-panel', 'ais');\n    aisFoldSteps('ais-sweeper-panel', 'sw');\n    aisFoldSteps('ais-djdrop-panel', 'dj');\n    aisFoldSteps('ais-podcast-panel', 'pc');\n  } catch(e){ if (window.console) console.warn('[RJ24] step folding:', e); }\n\n  \/* ---------------------------------------------------------------------\n     Carry the finished piece in the footer. Walking the tool as a new user,\n     the expensive move is not getting through the form once - it is the\n     tweak-then-listen loop afterwards, which was a ~2,500px round trip from\n     the sound library down to the player and back, every single swap.\n\n     These buttons drive the real controls in the result panel rather than\n     reimplementing them, so there is exactly one play path and one download\n     path however you reach them.\n     --------------------------------------------------------------------- *\/\n  (function aisFooterOutput(){\n    var TOOLS = [\n      { res:'ais-result-ready', audio:'ais-audio', dl:'ais-dl-main' },\n      { res:'sw-result-ready',  audio:'sw-audio',  dl:'sw-dl' },\n      { res:'dj-result-ready',  audio:'dj-audio',  dl:'dj-dl' },\n      { res:'pc-result-ready',  audio:'pc-audio',  dl:'pc-dl' }\n    ];\n    var bar = document.getElementById('ais-foot-out');\n    if (!bar) return;\n    var cur = null;\n\n    function live(){\n      for (var i = 0; i < TOOLS.length; i++){\n        var r = document.getElementById(TOOLS[i].res);\n        if (r && r.offsetParent !== null) return TOOLS[i];\n      }\n      return null;\n    }\n    function sync(){\n      cur = live();\n      bar.classList.toggle('on', !!cur);\n    }\n    bar.addEventListener('click', function(e){\n      var b = e.target.closest('[data-o]'); if (!b || !cur) return;\n      var what = b.getAttribute('data-o');\n      if (what === 'play'){\n        var au = document.getElementById(cur.audio); if (!au) return;\n        if (au.paused){ au.play(); b.innerHTML = '&#10073;&#10073; <span class=\"lbl\">Pause<\/span>'; }\n        else { au.pause(); b.innerHTML = '&#9654; <span class=\"lbl\">Play<\/span>'; }\n      } else if (what === 'dl'){\n        var dl = document.getElementById(cur.dl); if (dl) dl.click();\n      } else if (what === 'seg'){\n        \/* the Seg Edit button lives on the result panel; press that one so the\n           editor opens exactly as it does normally, then bring it into view *\/\n        var wrap = document.getElementById(cur.res + '-mixbtn');\n        var btn = wrap && wrap.querySelector('button');\n        var open = document.getElementById(cur.res + '-mixeditor');\n        if (open){ open.scrollIntoView({ behavior:'smooth', block:'center' }); }\n        else if (btn){ btn.click(); }\n      }\n    });\n\n    \/* Same caution as the two-column observer: this reads offsetParent, so it is\n       coalesced onto a frame and watches only the result panels rather than\n       every attribute change in the studio. *\/\n    var pend = 0;\n    function schedule(){\n      if (pend) return;\n      pend = requestAnimationFrame(function(){ pend = 0; sync(); });\n    }\n    if (window.MutationObserver){\n      var mo = new MutationObserver(schedule);\n      TOOLS.forEach(function(t){\n        var r = document.getElementById(t.res);\n        if (r) mo.observe(r, { attributes:true, attributeFilter:['style','class'] });\n      });\n    }\n    setInterval(sync, 900);   \/* backstop: some panels are shown without a mutation we see *\/\n    sync();\n  })();\n\n  \/* A shadow under the tabs only once they are actually stuck, so the page does\n     not look permanently divided. *\/\n  (function aisStuckTabs(){\n    var tabs = document.querySelector('#rj-ai-studio .ais-tabs');\n    if (!tabs || !window.IntersectionObserver) return;\n    var probe = document.createElement('div');\n    probe.style.cssText = 'position:absolute;height:1px;width:1px;';\n    tabs.parentNode.insertBefore(probe, tabs);\n    new IntersectionObserver(function(e){\n      tabs.classList.toggle('ais-stuck', !e[0].isIntersecting);\n    }, { threshold:0 }).observe(probe);\n  })();\n\n  \/* ---------------------------------------------------------------------\n     Mark whichever tool panel is actually on screen, whether it has produced\n     anything yet, and whether Seg Edit is open. Three classes; the layout is\n     entirely CSS from there.\n     --------------------------------------------------------------------- *\/\n  (function aisTwoColumn(){\n    var PANELS = ['ais-advert-panel','ais-sweeper-panel','ais-djdrop-panel','ais-podcast-panel'];\n    function sync(){\n      var anyLive = false;\n      for (var i = 0; i < PANELS.length; i++){\n        var p = document.getElementById(PANELS[i]);\n        if (!p) continue;\n        \/* switchTool writes this, and unlike offsetParent it cannot be\n           contaminated by the display:grid !important we apply ourselves - which\n           is what previously let a hidden panel declare itself live for good. *\/\n        var live = p.style.display !== 'none';\n        if (live) anyLive = true;\n        p.classList.toggle('ais-live', live);\n        if (!live){ p.classList.remove('ais-has-result', 'ais-editing'); continue; }\n        var res = p.querySelector('.ais-result');\n        p.classList.toggle('ais-has-result', !!(res && res.offsetParent !== null));\n        p.classList.toggle('ais-editing', !!p.querySelector('.ais-mix'));\n      }\n      \/* the widening used to be a :has() selector; a class the JS already knows\n         how to set is one less thing for a CSS minifier to trip over *\/\n      var root = document.getElementById('rj-ai-studio');\n      if (root) root.classList.toggle('ais-tool-open', anyLive);\n    }\n    \/* Coalesced onto a frame. sync() reads offsetParent, which forces layout, so\n       running it once per mutation while the pickers insert a thousand nodes is\n       enough to lock the renderer solid - which is exactly what it did. *\/\n    var pending = 0;\n    function schedule(){\n      if (pending) return;\n      pending = requestAnimationFrame(function(){ pending = 0; sync(); });\n    }\n    if (window.MutationObserver){\n      var mo = new MutationObserver(schedule);\n      \/* the panels themselves, not the whole studio, and attributes only - the\n         library rendering into them is none of this observer's business *\/\n      PANELS.forEach(function(id){\n        var p = document.getElementById(id);\n        if (p) mo.observe(p, { attributes:true, attributeFilter:['style','class'] });\n      });\n    }\n    setInterval(sync, 700);\n    sync();\n  })();\n\n  \/* ---- RJ24-EDIT (pass zv): window.RJ24OpenLogin ---------------------\n     Defined here rather than left to the theme. Eight existing call sites\n     already ask for it by this name, so defining it upgrades all of them\n     without editing any. The theme version, if there is one, is kept and\n     used as the fallback should the panel fail to mount. *\/\n  (function accountPanel(){\n    var themeOpen = window.RJ24OpenLogin;   \/\/ whatever was there before us\n    var veil = document.getElementById(\"ais-acct-veil\");\n    if (!veil) { return; }                  \/\/ markup missing - leave the theme in charge\n\n    var form   = document.getElementById(\"ais-acct-form\");\n    var email  = document.getElementById(\"ais-acct-email\");\n    var pass   = document.getElementById(\"ais-acct-pass\");\n    var hp     = document.getElementById(\"ais-acct-hp\");\n    var go     = document.getElementById(\"ais-acct-go\");\n    var err    = document.getElementById(\"ais-acct-err\");\n    var title  = document.getElementById(\"ais-acct-title\");\n    var subEl  = document.getElementById(\"ais-acct-sub\");\n    var nocard = document.getElementById(\"ais-acct-nocard\");\n    var passLb = document.getElementById(\"ais-acct-pass-label\");\n    var altEl  = document.getElementById(\"ais-acct-alt\");\n    var swap   = document.getElementById(\"ais-acct-swap\");\n    var mode   = \"register\";\n    var busy   = false;\n    var resume = null;      \/\/ the Create button that sent us here\n    var lastFocus = null;\n\n    \/* The REST root, derived from a URL the plugin already gave us rather\n       than assembled from the site address - CFG.ttsUrl is always present\n       and always absolute, and this page can be served from more than one\n       path (the studio page and the standalone landing page). *\/\n    function endpoint(name){\n      var C = window.RJ24_AISTUDIO_CFG || {};\n      var base = C.ttsUrl || C.meUrl || C.creationsUrl || \"\";\n      if (base) { return base.replace(\/\\\/(tts|me|creations)(\\\/)?$\/, \"\/\" + name); }\n      return \"\/wp-json\/rj24ais\/v1\/\" + name;\n    }\n\n    \/* Which Create button to press again once they are in. Captured on the\n       way down so it is recorded even though the click handler returns\n       early - and read at open() time, not stored globally, so a stale one\n       from ten minutes ago can never fire. *\/\n    var lastCreate = null;\n    document.addEventListener(\"click\", function(e){\n      var t = e.target;\n      while (t && t !== document) {\n        if (t.id && \/(^ais-create$|-create$)\/.test(t.id)) { lastCreate = t; return; }\n        t = t.parentNode;\n      }\n    }, true);\n\n    function showErr(msg){ err.textContent = msg; err.className = \"ais-acct-err show\"; }\n    \/* Same box, different reading. Used when nothing has gone wrong and the\n       customer just needs telling what is actually happening. *\/\n    function showNote(msg){ err.textContent = msg; err.className = \"ais-acct-err ais-acct-note show\"; }\n    function clearErr(){ err.textContent = \"\"; err.className = \"ais-acct-err\"; }\n\n    function paint(){\n      var free = !!(window.RJ24_AISTUDIO_CFG || {}).freeFirstEnabled;\n      if (mode === \"register\") {\n        title.textContent  = free ? \"Make your free one\" : \"Create your account\";\n        subEl.textContent  = \"Everything you have set up is still here. Pop in an email and a password and it will make it now.\";\n        nocard.textContent = \"No card. No payment details. Nothing to cancel.\";\n        nocard.style.display = \"\";\n        passLb.textContent = \"Choose a password\";\n        pass.setAttribute(\"autocomplete\", \"new-password\");\n        pass.placeholder   = \"At least 8 characters\";\n        go.textContent     = free ? \"Create my account & make it\" : \"Create my account\";\n        altEl.innerHTML    = 'Already have an account? <button type=\"button\" id=\"ais-acct-swap\">Sign in<\/button>';\n      } else {\n        title.textContent  = \"Sign in\";\n        subEl.textContent  = \"Everything you have set up is still here - signing in will carry straight on with it.\";\n        nocard.style.display = \"none\";\n        passLb.textContent = \"Password\";\n        pass.setAttribute(\"autocomplete\", \"current-password\");\n        pass.placeholder   = \"\";\n        go.textContent     = \"Sign in & carry on\";\n        altEl.innerHTML    = 'Need an account? <button type=\"button\" id=\"ais-acct-swap\">Create one<\/button>';\n      }\n      document.getElementById(\"ais-acct-swap\").addEventListener(\"click\", function(){\n        mode = (mode === \"register\") ? \"signin\" : \"register\";\n        clearErr(); paint(); email.focus();\n      });\n      \/* The reset link. Hidden while registering (there is nothing to reset\n         yet) and shown while signing in. Carries the typed email through so\n         WooCommerce's form arrives already filled in. *\/\n      var lost = document.getElementById(\"ais-acct-lost\");\n      var lostA = document.getElementById(\"ais-acct-lost-link\");\n      var lostUrl = (window.RJ24_AISTUDIO_CFG || {}).lostPasswordUrl || \"\";\n      if (lost && lostA) {\n        if (mode === \"signin\" && lostUrl) {\n          var em = (email.value || \"\").trim();\n          lostA.href = lostUrl + (lostUrl.indexOf(\"?\") > -1 ? \"&\" : \"?\")\n                     + \"user_login=\" + encodeURIComponent(em);\n          lostA.target = \"_blank\";\n          lostA.rel = \"nofollow noopener\";\n          lost.style.display = \"\";\n        } else {\n          lost.style.display = \"none\";\n        }\n      }\n    }\n\n    function open(which){\n      mode = (which === \"signin\" || which === \"login\") ? \"signin\" : \"register\";\n      resume = lastCreate;\n      lastFocus = document.activeElement;\n      clearErr(); paint();\n      veil.classList.add(\"show\");\n      veil.setAttribute(\"aria-hidden\", \"false\");\n      setTimeout(function(){ email.focus(); }, 30);\n    }\n    function close(){\n      veil.classList.remove(\"show\");\n      veil.setAttribute(\"aria-hidden\", \"true\");\n      if (lastFocus && lastFocus.focus) { try { lastFocus.focus(); } catch(e){} }\n    }\n\n    document.getElementById(\"ais-acct-x\").addEventListener(\"click\", close);\n    veil.addEventListener(\"click\", function(e){ if (e.target === veil) close(); });\n    document.addEventListener(\"keydown\", function(e){\n      if (e.key === \"Escape\" && veil.classList.contains(\"show\")) close();\n    });\n\n    form.addEventListener(\"submit\", function(e){\n      e.preventDefault();\n      if (busy) { return; }\n      clearErr();\n      var em = (email.value || \"\").trim();\n      var pw = pass.value || \"\";\n      if (em.indexOf(\"@\") < 1) { showErr(\"Please enter your email address.\"); email.focus(); return; }\n      if (mode === \"register\" && pw.length < 8) {\n        showErr(\"Please choose a password of at least 8 characters.\"); pass.focus(); return;\n      }\n      if (!pw) { showErr(\"Please enter your password.\"); pass.focus(); return; }\n\n      busy = true; go.disabled = true;\n      var was = go.textContent;\n      go.textContent = (mode === \"register\") ? \"Creating your account\\u2026\" : \"Signing you in\\u2026\";\n\n      var C = window.RJ24_AISTUDIO_CFG || {};\n      var H = { \"Content-Type\": \"application\/json\" };\n      if (C.nonce) { H[\"X-WP-Nonce\"] = C.nonce; }\n      var body = { email: em, password: pw };\n      if (mode === \"register\") { body.website = hp ? hp.value : \"\"; }\n\n      fetch(endpoint(mode === \"register\" ? \"register\" : \"signin\"), {\n        method: \"POST\", credentials: \"same-origin\",\n        headers: H, body: JSON.stringify(body)\n      }).then(function(r){\n        return r.json().then(function(j){ return { status: r.status, body: j }; });\n      }).then(function(res){\n        busy = false; go.disabled = false; go.textContent = was;\n        var j = res.body || {};\n        if (res.status >= 400 || !j.ok) {\n          var m = (j && j.message) ? j.message : \"Something went wrong. Please try again.\";\n          var d = j.data || {};\n\n          \/* 404 + noAccount: there is no user with that email. This is the\n             case that has been generating support email for years - the\n             password reset they were about to ask for could never have\n             arrived, because there was nothing to reset. Send them to the\n             register form rather than round the loop again. *\/\n          if (res.status === 404 && d.noAccount) {\n            mode = \"register\";\n            paint();                 \/\/ repaint FIRST - it rewrites the box\n            pass.value = \"\";\n            if (d.pastOrders > 0) {\n              showNote(\"You have ordered with us \" + d.pastOrders + \" time\"\n                + (d.pastOrders === 1 ? \"\" : \"s\") + \" before but never had an account\"\n                + \" - which is why a password reminder never arrived. Choose a password\"\n                + \" now and your orders will be waiting inside.\");\n              \/* Hide the reset link here. It is precisely the thing that has\n                 been wasting their time, and leaving it visible invites\n                 another go at it. *\/\n              var lostEl = document.getElementById(\"ais-acct-lost\");\n              if (lostEl) { lostEl.style.display = \"none\"; }\n            } else {\n              showNote(\"There is no account with that email yet. Choose a password and that is you set up.\");\n            }\n            pass.focus();\n            return;\n          }\n\n          \/* 409: the email already has an account. Paul hit this with his\n             own address and reported it as \"it opened the same thing again\n             and I had to click again\" - which is exactly what it did. If\n             the password they typed is their real one, that second click\n             achieves nothing, so try it for them. Only a genuinely wrong\n             password stops and asks, and \/signin cannot answer 409, so this\n             can retry at most once. *\/\n          if (res.status === 409) {\n            mode = \"signin\";\n            paint();\n            form.dispatchEvent(new Event(\"submit\", { cancelable:true, bubbles:true }));\n            return;\n          }\n\n          showErr(m);\n          \/* A wrong password is exactly when somebody needs the reset link,\n             and this path does not repaint. Refresh it in place so the href\n             carries the email they have just typed. *\/\n          if (res.status === 401) { paint(); pass.value = \"\"; pass.focus(); }\n          return;\n        }\n\n        \/* Swap in everything the server just told us - the nonce FIRST,\n           because every call after this authenticates with it. *\/\n        var cfg = window.RJ24_AISTUDIO_CFG || (window.RJ24_AISTUDIO_CFG = {});\n        if (j.nonce) { cfg.nonce = j.nonce; }\n        cfg.loggedIn = true;\n        if (typeof j.credits === \"number\") { cfg.credits = j.credits; }\n        if (typeof j.freeFirstEnabled   !== \"undefined\") { cfg.freeFirstEnabled   = j.freeFirstEnabled; }\n        if (typeof j.freeFirstAvailable !== \"undefined\") { cfg.freeFirstAvailable = j.freeFirstAvailable; }\n        if (typeof j.freeFirstMaxWords  !== \"undefined\") { cfg.freeFirstMaxWords  = j.freeFirstMaxWords; }\n        if (j.accountUrl) { cfg.accountUrl = j.accountUrl; }\n        if (j.topUpUrl)   { cfg.topUpUrl   = j.topUpUrl; }\n\n        close();\n        \/* The form snapshot only exists to survive a navigation. There is\n           no navigation any more, so drop it - otherwise a later reload\n           would restore a script they have already had made. *\/\n        if (window.RJ24AISClearForm) { try { window.RJ24AISClearForm(); } catch(e){} }\n        \/* If they turned out to be an old customer, say so. By this point\n           wc_update_new_customer_past_orders() has already attached the\n           orders, so this is a statement of fact, not a promise. *\/\n        if (j.linkedOrders > 0 && typeof toast === \"function\") {\n          toast(\"Welcome back \\u2014 your \" + j.linkedOrders + \" previous order\"\n            + (j.linkedOrders === 1 ? \" is\" : \"s are\") + \" now in your account.\", \"\");\n        }\n        window.dispatchEvent(new CustomEvent(\"rj24ais:signedin\", { detail: j }));\n\n        \/* And do the thing they originally asked for. *\/\n        if (resume && resume.click) { setTimeout(function(){ resume.click(); }, 120); }\n      })[\"catch\"](function(){\n        busy = false; go.disabled = false; go.textContent = was;\n        showErr(\"Could not reach the server. Check your connection and try again.\");\n      });\n    });\n\n    paint();\n\n    \/* Published last, and defended: some themes assign RJ24OpenLogin on\n       their own DOMContentLoaded, which can run after this. A setter that\n       remembers but does not replace keeps the good panel in charge while\n       still holding on to theirs as the fallback. *\/\n    function openLogin(which){\n      try { open(which); }\n      catch(e) { if (themeOpen) { themeOpen(which); } }\n    }\n    try {\n      Object.defineProperty(window, \"RJ24OpenLogin\", {\n        configurable: true,\n        get: function(){ return openLogin; },\n        set: function(v){ themeOpen = v; }\n      });\n    } catch(e) { window.RJ24OpenLogin = openLogin; }\n  })();\n  \/* ---- RJ24-EDIT (pass zy): deep link + landing-page handoff ----------\n     Runs at the very end of setup, so switchTool and every tool panel are\n     already wired. Deliberately silent if there is no hash and no handoff:\n     an ordinary visit is completely unaffected. *\/\n  (function arriveReady(){\n    var TOOLS = { advert:1, sweeper:1, djdrop:1, podcast:1, sung:1, sty:1, creations:1 };\n    var SCRIPT_BOX = { advert:\"ais-script\", sweeper:\"sw-script\", djdrop:\"dj-script\", podcast:\"pc-script\" };\n    var HANDOFF = \"rj24ais_handoff_v1\";\n\n    function chosenTool(){\n      var h = (window.location.hash || \"\").replace(\/^#\/, \"\").toLowerCase();\n      return TOOLS[h] ? h : \"\";\n    }\n\n    function readHandoff(){\n      try {\n        var raw = sessionStorage.getItem(HANDOFF);\n        if (!raw) { return null; }\n        \/* Read once and drop it immediately - BEFORE using it, so a parse\n           failure cannot leave a poison value that breaks every later load. *\/\n        sessionStorage.removeItem(HANDOFF);\n        var o = JSON.parse(raw);\n        if (!o || typeof o.text !== \"string\") { return null; }\n        return { tool: TOOLS[o.tool] ? o.tool : \"sweeper\", text: o.text.slice(0, 600) };\n      } catch(e) { return null; }\n    }\n\n    \/* pass zz: pick a voice so the first press of Create cannot fail.\n       The cards are rendered from an API call, so this waits for them\n       rather than assuming. Clicking the card is deliberate - it is the\n       real code path that sets st.voice, so nothing can drift out of step\n       with what the tool believes is selected. *\/\n    function ensureVoice(tool, attempt){\n      var wrap = el(({ advert:\"ais\", sweeper:\"sw\", djdrop:\"dj\", podcast:\"pc\", sty:\"sty\" }[tool] || \"sw\") + \"-voices\");\n      if (!wrap) { return; }\n      if (wrap.querySelector(\".ais-voice.selected\")) { return; }   \/\/ they chose one\n      var first = wrap.querySelector(\".ais-voice\");\n      if (first) { first.click(); return; }\n      if ((attempt || 0) < 20) { setTimeout(function(){ ensureVoice(tool, (attempt||0)+1); }, 400); }\n    }\n\n    setTimeout(function(){\n      var hand = readHandoff();\n      var tool = chosenTool() || (hand ? hand.tool : \"\");\n      if (!tool && !hand) { return; }\n\n      if (tool && typeof switchTool === \"function\") {\n        try { switchTool(tool); } catch(e){}\n      }\n\n      \/* THE STRUCTURAL BIT. Measured on the live page: the sell block is\n         2,452px tall and the sweeper script box sits 3,364px down - almost\n         four screens. Anybody arriving with a #tool hash or a handoff has\n         just come off the landing page and read all of it. Showing it to\n         them again is a wall between them and the thing they asked for. *\/\n      var sell = el(\"ais-sell\");\n      if (sell) { sell.style.display = \"none\"; }\n\n      if (tool) { ensureVoice(tool, 0); }\n\n      \/* A #tool hash with no handoff used to select the tab and leave the\n         page at scroll 0, i.e. do nothing a visitor could see. Treat it the\n         same as a handoff with no text. *\/\n      if (!hand && tool) { hand = { tool: tool, text: \"\" }; }\n\n      if (hand) {\n        var box = el(SCRIPT_BOX[hand.tool]);\n        if (box && hand.text && !box.value.trim()) {\n          box.value = hand.text;\n          \/* Setting .value fires nothing, so the cost line, the word count\n             and the free-preview banner would all still read empty. Same\n             trap RJ24AISSyncRestored() exists for. *\/\n          box.dispatchEvent(new Event(\"input\",  { bubbles:true }));\n          box.dispatchEvent(new Event(\"change\", { bubbles:true }));\n        }\n        \/* Put them where the work is, not at the top of a long page.\n           With the sell block hidden this is now a short hop rather than a\n           four-screen jump, but it still has to settle - see below.\n\n           Scrolling once does not hold. This is a 4,300px page with lazily\n           laid-out content above the tools, so a scroll computed before it\n           settles overshoots - measured at 656px past the box, which leaves\n           the customer looking at the wrong thing with no idea their line\n           was ever carried over. So it re-scrolls a few times over the first\n           second, and again on load if that has not happened yet, which\n           corrects each late shift. \"auto\" not \"smooth\" on purpose: repeated\n           smooth scrolls fight each other and animate visibly. *\/\n        if (box && box.scrollIntoView) {\n          var tries = 0;\n          var settle = function(){\n            try { box.scrollIntoView({ behavior:\"auto\", block:\"center\" }); }\n            catch(e){ box.scrollIntoView(); }\n            \/* Re-assert the line as well as the position. The sound library\n               loads asynchronously and its arrival (or failure) re-renders\n               the cost line - which, landing after our fill, was overwriting\n               \"Cost: FREE\" with the ordinary price even though the free grant\n               was intact. Firing input again is idempotent and puts the last\n               word back where it belongs. *\/\n            box.dispatchEvent(new Event(\"input\", { bubbles:true }));\n            if (++tries < 4) { setTimeout(settle, 250); }\n          };\n          if (document.readyState === \"complete\") { setTimeout(settle, 60); }\n          else { window.addEventListener(\"load\", function(){ setTimeout(settle, 60); }); }\n          try { box.focus({ preventScroll:true }); } catch(e){}\n        }\n      }\n    }, 0);\n  })();\n  \/* RJ24-EDIT (Aug 2026): built LAST, on purpose. buildSfxPicker reads\n     LIB_SLOTS, and the podcast picker needs `pc` - both are `var`s declared\n     further up but assigned further down. Calling either one earlier throws\n     on a hoisted undefined and takes the whole script with it. *\/\n  try {\n    buildSfxPicker('ais', st, function(){ refreshCost(); }, { slots:['bed'] });\n    buildSfxPicker('pc', pc, function(){ pcRefresh(); }, { slots:['bed'] });\n  } catch(e){ if (window.console) console.warn(\"[RJ24] bed picker init:\", e); }\n})();\n<\/script>\n\n<!-- ===========================================================================\n     RJ24 VOICE PICKER v2 - merged in Aug 2026.\n     This used to be its own WordPress block, which is why the fix that stops\n     the Sweeper and DJ Drop tabs hiding 1,270 voices had to be applied twice.\n     It guards on window.__RJ24_VP_V2_LOADED__, so running last here means this\n     copy claims the flag first and any older copy still on the page returns\n     immediately without drawing anything.\n     ======================================================================== -->\n<script id=\"rj24-vp-v2-script\">\n    (function() {\n        'use strict';\n        \/\/ GUARD 1: stop the entire IIFE running twice if the script tag\n        \/\/ somehow gets included on the page more than once.\n        if (window.__RJ24_VP_V2_LOADED__) return;\n        window.__RJ24_VP_V2_LOADED__ = true;\n        var FAV_KEY='rj24_vp_favs_v1',BLOCK_KEY='rj24_vp_blocks_v1';\n        function loadSet(k){try{return new Set(JSON.parse(localStorage.getItem(k)||'[]'));}catch(e){return new Set();}}\n        function saveSet(k,s){try{localStorage.setItem(k,JSON.stringify([...s]));}catch(e){}}\n        var FAVS=loadSet(FAV_KEY),BLOCKS=loadSet(BLOCK_KEY);\n        \/* Up here because the waitFor below calls init() synchronously when the\n           markup is already present, and init() renders the voice grids. These\n           are `var`s: they do not hoist their values the way the functions do,\n           and an undefined page size sliced every grid down to nothing. *\/\n        var CA=null,CB=null,PS=8;   \/* playing audio, its button, page size *\/\n        function waitFor(c,cb,t){t=t||0;try{if(c()){cb();return;}}catch(e){}if(t>80)return;setTimeout(function(){waitFor(c,cb,t+1);},100);}\n        waitFor(function(){return document.getElementById('rj-ai-studio')&&window.RJ24_AISTUDIO_CFG&&Array.isArray(window.RJ24_AISTUDIO_CFG.voices)&&window.RJ24_AISTUDIO_CFG.voices.length&&document.getElementById('ais-voices');},init);\n        function init(){\n            var ALL=window.RJ24_AISTUDIO_CFG.voices;\n            var IMG=ALL.filter(function(v){return v.is_imaging;});\n            \/* RJ24-EDIT (Aug 2026, customer feedback): imaging voices are a SORT ORDER,\n               not a filter. This replaced the whole list with the imaging-flagged\n               subset on Sweeper and DJ Drop, so those two tabs showed a handful\n               while every other tab showed all 1,279. Three customers reported it:\n               a voice heard on one tab missing on another, and \"only 6 UK voices,\n               all male\". Nothing is hidden now - imaging reads simply sort first,\n               and the Imaging-only quick filter still narrows to them on demand. *\/\n            function pv(p){\n              if(p!=='sw'&&p!=='dj')return ALL;\n              return ALL.slice().sort(function(a,b){\n                var ai=a.is_imaging?0:1,bi=b.is_imaging?0:1;\n                if(ai!==bi)return ai-bi;\n                return String(a.name||'').localeCompare(String(b.name||''));\n              });\n            }\n            ['ais','sw','dj','pc'].forEach(function(p){wirePicker(p,pv(p));});\n        }\n        var CLASSIFY=[['northern irish','UK','Northern Irish'],['ulster','UK','Northern Irish'],['belfast','UK','Northern Irish'],['welsh','UK','Welsh'],['glaswegian','UK','Scottish'],['scottish','UK','Scottish'],['edinburgh','UK','Scottish'],['highland','UK','Scottish'],['yorkshire','UK','Yorkshire'],['leeds','UK','Yorkshire'],['sheffield','UK','Yorkshire'],['geordie','UK','Geordie'],['newcastle','UK','Geordie'],['scouse','UK','Scouse'],['liverpool','UK','Scouse'],['mancunian','UK','Mancunian'],['manchester','UK','Mancunian'],['brummie','UK','Brummie'],['birmingham','UK','Brummie'],['cockney','UK','Cockney'],['east london','UK','Cockney'],['estuary','UK','Estuary'],['received pronunciation','UK','RP \/ Posh'],['posh','UK','RP \/ Posh'],['west country','UK','West Country'],['bristol','UK','West Country'],['en-british','UK','British (general)'],['british','UK','British (general)'],['english','UK','British (general)'],['irish','Ireland','Irish'],['dublin','Ireland','Dublin'],['us southern','USA','Southern'],['southern us','USA','Southern'],['african american','USA','African American'],['chicago','USA','Chicago'],['us northeast','USA','Northeast'],['northeast','USA','Northeast'],['new england','USA','Northeast'],['philadelphia','USA','Northeast'],['pittsburgh','USA','Northeast'],['new york','USA','New York'],['midwest','USA','Midwest'],['californ','USA','Californian'],['texan','USA','Southern'],['boston','USA','Boston'],['american','USA','American (general)'],['canadian','Canada','Canadian'],['australian','Australia','Australian'],['aussie','Australia','Australian'],['new zealand','New Zealand','NZ'],['kiwi','New Zealand','NZ'],['south african','South Africa','South African'],['indian','India','Indian']];\n        function classify(a){var s=(a||'').toLowerCase();for(var i=0;i<CLASSIFY.length;i++){if(s.indexOf(CLASSIFY[i][0])!==-1)return{country:CLASSIFY[i][1],region:CLASSIFY[i][2]};}return{country:'Other Countries',region:a?(a.charAt(0).toUpperCase()+a.slice(1)):''};}\n        function vCountry(v){if(v.country&&['UK','USA','Ireland','Canada','Australia','New Zealand','South Africa','India','Other Countries','Other'].indexOf(v.country)!==-1)return v.country;return classify(v.country||(v.meta||'').split('\u00b7')[0]).country;}\n        function vRegion(v){if(v.region)return v.region;return classify((v.meta||'').split('\u00b7')[0]||v.country||'').region;}\n        function vGender(v){if(v.gender&&v.gender!=='Unknown')return v.gender;var s=((v.meta||'')+' '+(v.type||'')).toLowerCase();if(s.indexOf('female')!==-1)return'Female';if(s.indexOf('male')!==-1)return'Male';return'Unknown';}\n        function isDefault(v){return(v.category||'')==='premade';}\n        function wirePicker(prefix,voices,retries){\n            retries=retries||0;\n            var ce=document.getElementById(prefix+'-voice-controls'),ge=document.getElementById(prefix+'-voices');\n            if(!ce||!ge){if(retries>20)return;setTimeout(function(){wirePicker(prefix,voices,retries+1);},500);return;}\n            \/\/ GUARD 3a: per-prefix dataset flag \u2014 if this picker was already\n            \/\/ wired (init ran twice, or wirePicker called twice for the same\n            \/\/ prefix via compound retries), skip silently.\n            if(ce.dataset.rj24Wired==='1')return;\n            \/\/ GUARD 3b: defensive DOM check \u2014 if our picker is already\n            \/\/ inserted as the previous sibling of the original controls,\n            \/\/ skip. Covers the case where dataset was somehow stripped.\n            if(ce.previousElementSibling&&ce.previousElementSibling.classList&&ce.previousElementSibling.classList.contains('rj24-vp-v2'))return;\n            ce.dataset.rj24Wired='1';\n            ce.style.cssText='position:absolute;left:-99999px;width:1px;height:1px;overflow:hidden;';\n            ge.style.cssText='position:absolute;left:-99999px;width:1px;height:1px;overflow:hidden;';\n            var ne=document.createElement('div');ne.className='rj24-vp-v2';\n            ne.innerHTML='<div class=\"rj24-vp-v2-quality\"><button type=\"button\" class=\"rj24-vp-v2-qp active\" data-q=\"all\">All voices<\/button><button type=\"button\" class=\"rj24-vp-v2-qp\" data-q=\"img\">\ud83d\udcfb Imaging only<\/button><button type=\"button\" class=\"rj24-vp-v2-qp\" data-q=\"favs\">\u2b50 Favourites only<\/button><\/div><div class=\"rj24-vp-v2-row\"><div class=\"rj24-vp-v2-field\"><label>Country<\/label><select class=\"rj24-vp-v2-country\"><option value=\"\">\u2014 pick a country \u2014<\/option><\/select><\/div><div class=\"rj24-vp-v2-field\"><label>Gender<\/label><select class=\"rj24-vp-v2-gender\" disabled><option value=\"\">(country first)<\/option><\/select><\/div><div class=\"rj24-vp-v2-field\"><label>Accent<\/label><select class=\"rj24-vp-v2-accent\" disabled><option value=\"\">(country first)<\/option><\/select><\/div><div class=\"rj24-vp-v2-field\" style=\"flex:2;min-width:200px\"><label>Search by name<\/label><input type=\"search\" class=\"rj24-vp-v2-search\" placeholder=\"e.g. Sophie\"><\/div><\/div><div class=\"rj24-vp-v2-count\"><span><b class=\"rj24-vp-v2-shown\">0<\/b> of <b class=\"rj24-vp-v2-total\">0<\/b> voices \u00b7 <span class=\"rj24-vp-v2-meta\"><\/span><\/span><button type=\"button\" class=\"rj24-vp-v2-clear\">Clear filters<\/button><\/div><div class=\"rj24-vp-v2-body\"><\/div>';\n            ce.parentNode.insertBefore(ne,ce);\n            var $c=ne.querySelector('.rj24-vp-v2-country'),$g=ne.querySelector('.rj24-vp-v2-gender'),$a=ne.querySelector('.rj24-vp-v2-accent'),$s=ne.querySelector('.rj24-vp-v2-search'),$cl=ne.querySelector('.rj24-vp-v2-clear'),$b=ne.querySelector('.rj24-vp-v2-body'),$sh=ne.querySelector('.rj24-vp-v2-shown'),$t=ne.querySelector('.rj24-vp-v2-total'),$m=ne.querySelector('.rj24-vp-v2-meta'),$q=ne.querySelectorAll('.rj24-vp-v2-qp');\n            var st={country:'',gender:'',region:'',search:'',page:0,selectedId:null,quality:'all'};\n            function qp(){if(st.quality==='favs')return voices.filter(function(v){return FAVS.has(v.id);});if(st.quality==='img')return voices.filter(function(v){return !BLOCKS.has(v.id)&&v.is_imaging;});return voices.filter(function(v){return !BLOCKS.has(v.id);});}\n            \/\/ v1.10.4: popularity scoring \u2014 higher = ranks first. Real\n            \/\/ cloned_by_count when present; premade\/cloned voices without\n            \/\/ popularity data get a middling default so they don't sink.\n            function popScore(v){var p=parseInt(v.popularity||0,10);if(p>0)return p;if((v.category||'')==='premade'||(v.category||'')==='cloned')return 500;return 1;}\n            function rc(){var p=qp(),cs={};p.forEach(function(v){var c=vCountry(v);cs[c]=(cs[c]||0)+1;});var o=['UK','USA','Ireland','Canada','Australia','New Zealand','South Africa','India','Other Countries','Other'],cv=$c.value;$c.innerHTML='<option value=\"\">\u2014 pick a country \u2014<\/option>';o.forEach(function(c){if(cs[c]){var op=document.createElement('option');op.value=c;op.textContent=c+' ('+cs[c]+')';$c.appendChild(op);}});Object.keys(cs).forEach(function(c){if(o.indexOf(c)===-1){var op=document.createElement('option');op.value=c;op.textContent=c+' ('+cs[c]+')';$c.appendChild(op);}});if(cv&&cs[cv])$c.value=cv;else st.country='';}\n            function rga(){$g.innerHTML='<option value=\"\">All genders<\/option>';$a.innerHTML='<option value=\"\">All accents<\/option>';if(!st.country){$g.disabled=true;$a.disabled=true;return;}$g.disabled=false;$a.disabled=false;var p=qp().filter(function(v){return vCountry(v)===st.country;});var gc={};p.forEach(function(v){var g=vGender(v);gc[g]=(gc[g]||0)+1;});['Female','Male','Unknown'].forEach(function(g){if(gc[g]){var op=document.createElement('option');op.value=g;op.textContent=(g==='Unknown'?'Unspecified':g)+' ('+gc[g]+')';$g.appendChild(op);}});var rcc={};p.forEach(function(v){var r=vRegion(v)||'(none)';rcc[r]=(rcc[r]||0)+1;});Object.keys(rcc).sort(function(x,y){var xg=x.indexOf('(general)')!==-1?1:0,yg=y.indexOf('(general)')!==-1?1:0;if(xg!==yg)return xg-yg;return x.localeCompare(y);}).forEach(function(r){var op=document.createElement('option');op.value=r;op.textContent=r+' ('+rcc[r]+')';$a.appendChild(op);});}\n            function gf(){var r=qp().filter(function(v){if(st.country&&vCountry(v)!==st.country)return false;if(st.gender&&vGender(v)!==st.gender)return false;if(st.region&&(vRegion(v)||'(none)')!==st.region)return false;if(st.search){var q=st.search.toLowerCase(),h=(v.name+' '+(v.meta||'')+' '+vRegion(v)).toLowerCase();if(h.indexOf(q)===-1)return false;}return true;});r.sort(function(a,b){var pa=popScore(a),pb=popScore(b);if(pa!==pb)return pb-pa;return(a.name||'').localeCompare(b.name||'');});return r;}\n            function rd(){\n              \/\/ RJ24 v1.11.0 \u2014 P0-1: never show an empty voice grid.\n              \/\/ With no filters applied we now lead with the imaging-curated,\n              \/\/ previewable voices so a first-time user always has something to\n              \/\/ hear immediately, instead of the old \"Pick a country to see\n              \/\/ voices\" dead end. Same render path already used by the\n              \/\/ Imaging-only \/ Favourites-only filters, so no new risk.\n              var all = gf();\n              var unfiltered = (!st.country && !st.search && !st.gender && !st.region && st.quality === 'all');\n              var f = all, isRec = false;\n              if (unfiltered) {\n                var rec = all.filter(function(v){ return v.is_imaging && v.demo; });\n                if (rec.length < PS) rec = all.filter(function(v){ return v.demo; });\n                if (rec.length) { f = rec; isRec = true; }\n              }\n              $t.textContent = all.length;\n              \/\/ P0-8: spell the counter out instead of the bare glyphs.\n              $m.innerHTML = '\u2b50 <b>' + voices.filter(function(v){ return FAVS.has(v.id); }).length + '<\/b> saved \u00b7 \u2717 <b>' + voices.filter(function(v){ return BLOCKS.has(v.id); }).length + '<\/b> hidden';\n              if (f.length === 0) { $b.innerHTML = '<div class=\"rj24-vp-v2-empty\"><b>No voices match<\/b><\/div>'; $sh.textContent = '0'; return; }\n              var sl = f.slice(0, (st.page + 1) * PS);\n              $sh.textContent = sl.length;\n              $b.innerHTML = '';\n              if (isRec) {\n                var note = document.createElement('div');\n                note.className = 'rj24-vp-v2-recnote';\n                note.innerHTML = '<b>Recommended for radio imaging<\/b> \u2014 tap \u25b6 to hear one. Pick a country, or search by name, to browse all ' + all.length + ' voices.';\n                $b.appendChild(note);\n              }\n              var g = document.createElement('div'); g.className = 'rj24-vp-v2-grid';\n              sl.forEach(function(v){ g.appendChild(mc(v)); });\n              $b.appendChild(g);\n              if (sl.length < f.length) {\n                var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'rj24-vp-v2-load';\n                btn.textContent = 'Load ' + Math.min(PS, f.length - sl.length) + ' more (' + (f.length - sl.length) + ' left)';\n                btn.addEventListener('click', function(){ st.page++; rd(); });\n                $b.appendChild(btn);\n              }\n            }\n            function mc(v){var c=document.createElement('div');c.className='rj24-vp-v2-card';if(st.selectedId===v.id)c.classList.add('selected');var ac=document.createElement('div');ac.className='rj24-vp-v2-actions';var fb=document.createElement('button');fb.type='button';fb.className='rj24-vp-v2-actbtn fav'+(FAVS.has(v.id)?' on':'');fb.innerHTML=FAVS.has(v.id)?'\u2605':'\u2606';fb.addEventListener('click',function(e){e.stopPropagation();if(FAVS.has(v.id)){FAVS.delete(v.id);fb.innerHTML='\u2606';fb.classList.remove('on');}else{FAVS.add(v.id);fb.innerHTML='\u2605';fb.classList.add('on');}saveSet(FAV_KEY,FAVS);if(st.quality==='favs')rd();});ac.appendChild(fb);var bb=document.createElement('button');bb.type='button';bb.className='rj24-vp-v2-actbtn block';bb.innerHTML='\u2717';bb.title='Hide this voice forever';bb.addEventListener('click',function(e){e.stopPropagation();BLOCKS.add(v.id);FAVS.delete(v.id);saveSet(BLOCK_KEY,BLOCKS);saveSet(FAV_KEY,FAVS);rd();});ac.appendChild(bb);c.appendChild(ac);var n=document.createElement('div');n.className='rj24-vp-v2-card-name';n.textContent=v.name||'Voice';c.appendChild(n);var pop=parseInt(v.popularity||0,10);if(pop>0){var pc=document.createElement('div');pc.className='rj24-vp-v2-popline';pc.innerHTML='<span class=\"rj24-vp-v2-pop\">\ud83d\udc65 '+(pop>=1000?(pop\/1000).toFixed(pop>=10000?0:1)+'K':pop)+' uses<\/span>';c.appendChild(pc);}var ts=document.createElement('div');ts.className='rj24-vp-v2-card-tags';var r=vRegion(v);if(r){var t1=document.createElement('span');t1.className='rj24-vp-v2-tag';t1.textContent=r;ts.appendChild(t1);}var g=vGender(v);if(g==='Female'){var t2=document.createElement('span');t2.className='rj24-vp-v2-tag f';t2.textContent='Female';ts.appendChild(t2);}else if(g==='Male'){var t3=document.createElement('span');t3.className='rj24-vp-v2-tag m';t3.textContent='Male';ts.appendChild(t3);}if(v.is_imaging){var t4=document.createElement('span');t4.className='rj24-vp-v2-tag img';t4.textContent='imaging';ts.appendChild(t4);}c.appendChild(ts);if(v.demo){var p=document.createElement('button');p.type='button';p.className='rj24-vp-v2-play';p.innerHTML='\u25b6 Preview';p.addEventListener('click',function(e){e.stopPropagation();pp(v.demo,p);});c.appendChild(p);}c.addEventListener('click',function(e){if(e.target.closest('.rj24-vp-v2-play'))return;if(e.target.closest('.rj24-vp-v2-actions'))return;sv(v,c);});return c;}\n            function pp(u,b){if(CA){CA.pause();if(CB)CB.innerHTML='\u25b6 Preview';if(CA._src===u){CA=null;CB=null;return;}}var a=new Audio(u);a._src=u;CA=a;CB=b;a.play().then(function(){b.innerHTML='\u23f8 Stop';}).catch(function(){});a.addEventListener('ended',function(){b.innerHTML='\u25b6 Preview';CA=null;CB=null;});}\n            function sv(v,ce){st.selectedId=v.id;$b.querySelectorAll('.rj24-vp-v2-card').forEach(function(c){c.classList.remove('selected');});if(ce)ce.classList.add('selected');var oc=document.getElementById(prefix+'-voice-country'),ot=document.getElementById(prefix+'-voice-type'),os=document.getElementById(prefix+'-voice-search');if(oc){oc.value=vCountry(v);oc.dispatchEvent(new Event('change'));}if(ot){ot.value='';ot.dispatchEvent(new Event('change'));}if(os){os.value=v.name||'';os.dispatchEvent(new Event('input'));}setTimeout(function(){var c=ge.querySelector('.ais-voice[data-v=\"'+String(v.id).replace(\/\"\/g,'\\\\\"')+'\"]');if(c)c.click();},50);}\n            $q.forEach(function(b){b.addEventListener('click',function(){$q.forEach(function(x){x.classList.remove('active');});b.classList.add('active');st.quality=b.getAttribute('data-q');st.page=0;rc();rga();rd();});});\n            $c.addEventListener('change',function(){st.country=$c.value;st.gender='';st.region='';st.page=0;rga();rd();});\n            $g.addEventListener('change',function(){st.gender=$g.value;st.page=0;rd();});\n            $a.addEventListener('change',function(){st.region=$a.value;st.page=0;rd();});\n            var sT;$s.addEventListener('input',function(){clearTimeout(sT);sT=setTimeout(function(){st.search=$s.value.trim();st.page=0;rd();},150);});\n            $cl.addEventListener('click',function(){st.country='';st.gender='';st.region='';st.search='';st.page=0;st.selectedId=null;$c.value='';$g.value='';$a.value='';$s.value='';$g.disabled=true;$a.disabled=true;$g.innerHTML='<option value=\"\">(country first)<\/option>';$a.innerHTML='<option value=\"\">(country first)<\/option>';rd();});\n            rc();rd();\n        }\n    })();\n    <\/script>\n\n\t\t<\/div>\n\t<\/div>\n\n\t\t\t<\/div> \n\t\t<\/div>\n\t<\/div> \n<\/div><\/div>\n","protected":false},"excerpt":{"rendered":"#rj-ai-studio{ --pink:#EC4899; --pink-dark:#DB2777; --pink-light:#FDF2F8; --ink:#1E293B; --text:#475569; --muted:#94A3B8; --line:#E2E8F0; --gold:#F59E0B; --cream:#F4F2EC; font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; color:var(--ink); max-width:1000px; margin:0 auto; padding:24px 16px 160px; -webkit-font-smoothing:antialiased; line-height:1.5; } #rj-ai-studio *{ box-sizing:border-box; } \/* HERO *\/ #rj-ai-studio...","protected":false},"author":104,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-116853","page","type-page","status-publish"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.3 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Radio Jingles 24: Radio Jingles | DJ Drops | Podcast Intros | Sung Jingles<\/title>\n<meta name=\"description\" content=\"Radio Jingles, Sung Jingles, DJ Drops, and Podcast Intros for commercial, community and internet radio stations. UK Based. Prices from \u00a35. Order online.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/radiojingles24.com\/it\/ai-studio\/\" \/>\n<meta property=\"og:locale\" content=\"it_IT\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Radio Jingles 24: Radio Jingles | DJ Drops | Podcast Intros | Sung Jingles\" \/>\n<meta property=\"og:description\" content=\"Radio Jingles, Sung Jingles, DJ Drops, and Podcast Intros for commercial, community and internet radio stations. UK Based. Prices from \u00a35. Order online.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/radiojingles24.com\/it\/ai-studio\/\" \/>\n<meta property=\"og:site_name\" content=\"Radio Jingles 24: Radio Jingles | DJ Drops | Podcast Intros | Sung Jingles\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/radiojingles24\/\" \/>\n<meta property=\"article:modified_time\" content=\"2026-08-22T10:40:59+00:00\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:site\" content=\"@radiojingles24\" \/>\n<meta name=\"twitter:label1\" content=\"Tempo di lettura stimato\" \/>\n\t<meta name=\"twitter:data1\" content=\"358 minuti\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/ai-studio\\\/\",\"url\":\"https:\\\/\\\/radiojingles24.com\\\/ai-studio\\\/\",\"name\":\"Radio Jingles 24: Radio Jingles | DJ Drops | Podcast Intros | Sung Jingles\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/#website\"},\"datePublished\":\"2026-05-27T12:06:42+00:00\",\"dateModified\":\"2026-08-22T10:40:59+00:00\",\"description\":\"Radio Jingles, Sung Jingles, DJ Drops, and Podcast Intros for commercial, community and internet radio stations. UK Based. Prices from \u00a35. Order online.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/ai-studio\\\/#breadcrumb\"},\"inLanguage\":\"it-IT\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/radiojingles24.com\\\/ai-studio\\\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/ai-studio\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/radiojingles24.com\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"AI Studio\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/#website\",\"url\":\"https:\\\/\\\/radiojingles24.com\\\/\",\"name\":\"Radio Jingles 24: Radio Jingles | DJ Drops | Podcast Intros | Sung Jingles\",\"description\":\"Radio Jingles, Sung Jingles, DJ Drops, and Podcast Intros for commercial, community and internet radio stations. UK Based. Prices from \u00a35. Order online.\",\"publisher\":{\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/radiojingles24.com\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"it-IT\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/#organization\",\"name\":\"Radio Jingles 24\",\"url\":\"https:\\\/\\\/radiojingles24.com\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"it-IT\",\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/radiojingles24.com\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/RadioJingle1-1.jpg\",\"contentUrl\":\"https:\\\/\\\/radiojingles24.com\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/RadioJingle1-1.jpg\",\"width\":512,\"height\":158,\"caption\":\"Radio Jingles 24\"},\"image\":{\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/radiojingles24\\\/\",\"https:\\\/\\\/x.com\\\/radiojingles24\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Radio Jingles 24: Radio Jingles | DJ Drops | Introduzione ai podcast | Jingles cantati","description":"Jingles radiofonici, Jingles cantati, DJ Drops e Podcast Intro per stazioni radio commerciali, comunitarie e Internet. Con sede nel Regno Unito. Prezzi a partire da \u00a3 5. Ordinare on-line.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/radiojingles24.com\/it\/ai-studio\/","og_locale":"it_IT","og_type":"article","og_title":"Radio Jingles 24: Radio Jingles | DJ Drops | Podcast Intros | Sung Jingles","og_description":"Radio Jingles, Sung Jingles, DJ Drops, and Podcast Intros for commercial, community and internet radio stations. UK Based. Prices from \u00a35. Order online.","og_url":"https:\/\/radiojingles24.com\/it\/ai-studio\/","og_site_name":"Radio Jingles 24: Radio Jingles | DJ Drops | Podcast Intros | Sung Jingles","article_publisher":"https:\/\/www.facebook.com\/radiojingles24\/","article_modified_time":"2026-08-22T10:40:59+00:00","twitter_card":"summary_large_image","twitter_site":"@radiojingles24","twitter_misc":{"Tempo di lettura stimato":"358 minuti"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/radiojingles24.com\/ai-studio\/","url":"https:\/\/radiojingles24.com\/ai-studio\/","name":"Radio Jingles 24: Radio Jingles | DJ Drops | Introduzione ai podcast | Jingles cantati","isPartOf":{"@id":"https:\/\/radiojingles24.com\/#website"},"datePublished":"2026-05-27T12:06:42+00:00","dateModified":"2026-08-22T10:40:59+00:00","description":"Jingles radiofonici, Jingles cantati, DJ Drops e Podcast Intro per stazioni radio commerciali, comunitarie e Internet. Con sede nel Regno Unito. Prezzi a partire da \u00a3 5. Ordinare on-line.","breadcrumb":{"@id":"https:\/\/radiojingles24.com\/ai-studio\/#breadcrumb"},"inLanguage":"it-IT","potentialAction":[{"@type":"ReadAction","target":["https:\/\/radiojingles24.com\/ai-studio\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/radiojingles24.com\/ai-studio\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/radiojingles24.com\/"},{"@type":"ListItem","position":2,"name":"AI Studio"}]},{"@type":"WebSite","@id":"https:\/\/radiojingles24.com\/#website","url":"https:\/\/radiojingles24.com\/","name":"Radio Jingles 24: Radio Jingles | DJ Drops | Introduzione ai podcast | Jingles cantati","description":"Jingles radiofonici, Jingles cantati, DJ Drops e Podcast Intro per stazioni radio commerciali, comunitarie e Internet. Con sede nel Regno Unito. Prezzi a partire da \u00a3 5. Ordinare on-line.","publisher":{"@id":"https:\/\/radiojingles24.com\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/radiojingles24.com\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"it-IT"},{"@type":"Organization","@id":"https:\/\/radiojingles24.com\/#organization","name":"Jingle radio 24","url":"https:\/\/radiojingles24.com\/","logo":{"@type":"ImageObject","inLanguage":"it-IT","@id":"https:\/\/radiojingles24.com\/#\/schema\/logo\/image\/","url":"https:\/\/radiojingles24.com\/wp-content\/uploads\/2019\/11\/RadioJingle1-1.jpg","contentUrl":"https:\/\/radiojingles24.com\/wp-content\/uploads\/2019\/11\/RadioJingle1-1.jpg","width":512,"height":158,"caption":"Radio Jingles 24"},"image":{"@id":"https:\/\/radiojingles24.com\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/radiojingles24\/","https:\/\/x.com\/radiojingles24"]}]}},"jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/pages\/116853","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/users\/104"}],"replies":[{"embeddable":true,"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/comments?post=116853"}],"version-history":[{"count":78,"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/pages\/116853\/revisions"}],"predecessor-version":[{"id":118410,"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/pages\/116853\/revisions\/118410"}],"wp:attachment":[{"href":"https:\/\/radiojingles24.com\/it\/wp-json\/wp\/v2\/media?parent=116853"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}