{"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-06-03T10:47:35","modified_gmt":"2026-06-03T10:47:35","slug":"ai-studio","status":"publish","type":"page","link":"https:\/\/radiojingles24.com\/de\/ai-studio\/","title":{"rendered":"KI-Studio"},"content":{"rendered":"\n\t\t<div id=\"fws_6a2109088c305\"  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#rj-ai-studio .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#rj-ai-studio .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#rj-ai-studio .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#rj-ai-studio .ais-cs-close:hover{ color:#1E293B; background:#F1F5F9; }\n#rj-ai-studio .ais-cs-title{ font-family:'Anton',sans-serif; text-transform:uppercase; font-size:22px; color:#1E293B; margin:0 0 4px; letter-spacing:-.3px; }\n#rj-ai-studio .ais-cs-detail{ color:#475569; font-size:14px; margin:0 0 16px; }\n#rj-ai-studio .ais-cs-detail b{ color:#1E293B; }\n#rj-ai-studio .ais-cs-actions{ display:flex; gap:10px; flex-wrap:wrap; }\n#rj-ai-studio .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#rj-ai-studio .ais-cs-topup{ background:#FFF1F8; color:#DB2777; border:1.5px solid #FBCFE8; }\n#rj-ai-studio .ais-cs-topup:hover{ background:#FCE7F3; border-color:#F9A8D4; }\n#rj-ai-studio .ais-cs-plans{ background:#EC4899; color:#fff; box-shadow:0 6px 18px rgba(236,72,153,.32); }\n#rj-ai-studio .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<\/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  <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\">Create your audio below<\/span><\/div>\n    <button type=\"button\" class=\"ais-slim-toggle\" id=\"ais-slim-toggle\">\u2139 What'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-hero\">\n      <div class=\"ais-badge\"><span class=\"dot\"><\/span> AI Studio &middot; New<\/div>\n      <h1>Radio imaging,<br><span class=\"pop\">made in seconds.<\/span><\/h1>\n      <p>Adverts, sweepers, DJ drops, podcast intros and sung jingles &mdash; written, voiced and produced by AI in seconds. Pick a style, choose a voice, and download a finished, royalty-free piece ready for air.<\/p>\n      <div class=\"ais-mini\">\n        <span>\u26a1 Ready in seconds<\/span>\n        <span>\ud83c\udfb5 AI voices &amp; music<\/span>\n        <span>\ud83d\udcfb Yours to keep<\/span>\n      <\/div>\n    <\/div>\n\n    <!-- AI + refunds disclaimer banner -->\n    <div class=\"ais-disclaimer\">\n      <div class=\"ais-disc-icon\">\ud83e\udd16<\/div>\n      <div>\n        <b>Everything here is made by AI.<\/b> Voices and music are computer-generated, so results vary and the odd quirk is part of the territory. Because each piece is produced instantly and credits are spent on generation, <b>AI Studio creations are non-refundable<\/b> (if a generation genuinely fails, your credits are returned automatically so you can try again).\n        <div class=\"ais-disc-human\">Want a <b>real human voice<\/b> or <b>professional singers and producers<\/b>? Those are a different service &mdash; browse our <a href=\"\/product\/commercial-production\/\">produced adverts<\/a> and <a href=\"\/product\/sung-audio\/\">human-sung jingles<\/a> on the main site.<\/div>\n      <\/div>\n    <\/div>\n\n    <!-- DEMO PLAYERS \u2014 one per product type. Replace the data-src values with your\n         own example MP3 URLs once you've made them. Slots with no src show \"coming soon\". -->\n    <div class=\"ais-demos\">\n      <h3 class=\"ais-demos-h\">Hear what AI Studio makes<\/h3>\n      <div class=\"ais-demo-grid\">\n        <div class=\"ais-demo\" data-src=\"https:\/\/stg-radiojingles24com-jinglestest.kinsta.cloud\/wp-content\/uploads\/2026\/05\/advert-AI.mp3\"><span class=\"ais-demo-ico\">\ud83c\udf99\ufe0f<\/span><div class=\"ais-demo-meta\"><b>Advert<\/b><small>Voiced + music bed<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">\u25b6<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/stg-radiojingles24com-jinglestest.kinsta.cloud\/wp-content\/uploads\/2026\/05\/ai-sweeper.mp3\"><span class=\"ais-demo-ico\">\ud83d\udce3<\/span><div class=\"ais-demo-meta\"><b>Sweeper<\/b><small>Punchy station imaging<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">\u25b6<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/stg-radiojingles24com-jinglestest.kinsta.cloud\/wp-content\/uploads\/2026\/05\/ai-drops.mp3\"><span class=\"ais-demo-ico\">\ud83c\udfa7<\/span><div class=\"ais-demo-meta\"><b>DJ Drop<\/b><small>Presenter shout<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">\u25b6<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/stg-radiojingles24com-jinglestest.kinsta.cloud\/wp-content\/uploads\/2026\/05\/aipodcast.mp3\"><span class=\"ais-demo-ico\">\ud83c\udfac<\/span><div class=\"ais-demo-meta\"><b>Podcast Intro<\/b><small>Intro \/ outro<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">\u25b6<\/button><\/div>\n        <div class=\"ais-demo\" data-src=\"https:\/\/stg-radiojingles24com-jinglestest.kinsta.cloud\/wp-content\/uploads\/2026\/05\/AI-Sung.mp3\"><span class=\"ais-demo-ico\">\ud83c\udfb5<\/span><div class=\"ais-demo-meta\"><b>Sung Jingle<\/b><small>Sung station logo<\/small><\/div><button type=\"button\" class=\"ais-demo-play\">\u25b6<\/button><\/div>\n      <\/div>\n      <p class=\"ais-demos-note\">Demos are AI-generated examples. Your results will be unique to your script and choices.<\/p>\n    <\/div>\n\n  <\/div>\n\t\t<style>\n\t\t#rj24ais-cta{\n\t\t\tmargin: 0 0 22px;\n\t\t\tpadding: 22px 26px;\n\t\t\tborder-radius: 18px;\n\t\t\tbackground: linear-gradient(135deg, #FDF2F8 0%, #FCE7F3 100%);\n\t\t\tborder: 1px solid #FBCFE8;\n\t\t\tfont-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n\t\t\tcolor: #1E293B;\n\t\t\tbox-shadow: 0 4px 20px rgba(236, 72, 153, 0.08);\n\t\t\tdisplay: flex;\n\t\t\talign-items: center;\n\t\t\tgap: 22px;\n\t\t\tflex-wrap: wrap;\n\t\t}\n\t\t#rj24ais-cta .rj24ais-cta-body { flex: 1; min-width: 240px; }\n\t\t#rj24ais-cta .rj24ais-cta-eyebrow {\n\t\t\tdisplay: inline-block;\n\t\t\tfont-size: 11px;\n\t\t\tfont-weight: 800;\n\t\t\tletter-spacing: 0.12em;\n\t\t\ttext-transform: uppercase;\n\t\t\tcolor: #DB2777;\n\t\t\tbackground: #fff;\n\t\t\tborder: 1px solid #FBCFE8;\n\t\t\tpadding: 4px 10px;\n\t\t\tborder-radius: 999px;\n\t\t\tmargin-bottom: 8px;\n\t\t}\n\t\t#rj24ais-cta h3 {\n\t\t\tfont-family: 'Anton', sans-serif;\n\t\t\ttext-transform: uppercase;\n\t\t\tfont-size: 26px;\n\t\t\tline-height: 1.05;\n\t\t\tmargin: 0 0 6px;\n\t\t\tcolor: #1E293B;\n\t\t\tletter-spacing: -0.3px;\n\t\t}\n\t\t#rj24ais-cta p { margin: 0; font-size: 14px; line-height: 1.5; color: #475569; }\n\t\t#rj24ais-cta .rj24ais-cta-actions {\n\t\t\tdisplay: flex;\n\t\t\tgap: 10px;\n\t\t\tflex-wrap: wrap;\n\t\t\talign-items: center;\n\t\t}\n\t\t#rj24ais-cta .rj24ais-cta-btn {\n\t\t\tdisplay: inline-flex;\n\t\t\talign-items: center;\n\t\t\tgap: 6px;\n\t\t\tpadding: 13px 22px;\n\t\t\tborder-radius: 999px;\n\t\t\tfont-weight: 800;\n\t\t\tfont-size: 14px;\n\t\t\ttext-decoration: none;\n\t\t\ttransition: background 0.15s, transform 0.1s, box-shadow 0.15s;\n\t\t\tborder: 2px solid transparent;\n\t\t\twhite-space: nowrap;\n\t\t}\n\t\t#rj24ais-cta .rj24ais-cta-primary {\n\t\t\tbackground: #EC4899;\n\t\t\tcolor: #fff;\n\t\t\tbox-shadow: 0 6px 18px rgba(236, 72, 153, 0.35);\n\t\t}\n\t\t#rj24ais-cta .rj24ais-cta-primary:hover {\n\t\t\tbackground: #DB2777;\n\t\t\tcolor: #fff;\n\t\t\ttransform: translateY(-1px);\n\t\t}\n\t\t#rj24ais-cta .rj24ais-cta-secondary {\n\t\t\tbackground: #fff;\n\t\t\tcolor: #DB2777;\n\t\t\tborder-color: #FBCFE8;\n\t\t}\n\t\t#rj24ais-cta .rj24ais-cta-secondary:hover {\n\t\t\tbackground: #FFF1F8;\n\t\t\tborder-color: #F9A8D4;\n\t\t\tcolor: #9D174D;\n\t\t}\n\t\t@media (max-width: 600px) {\n\t\t\t#rj24ais-cta { padding: 18px; gap: 14px; }\n\t\t\t#rj24ais-cta h3 { font-size: 22px; }\n\t\t\t#rj24ais-cta .rj24ais-cta-actions { width: 100%; }\n\t\t\t#rj24ais-cta .rj24ais-cta-btn { flex: 1; justify-content: center; }\n\t\t}\n\t\t<\/style>\n\t\t<div id=\"rj24ais-cta\">\n\t\t\t\t\t<div class=\"rj24ais-cta-body\">\n\t\t\t\t<span class=\"rj24ais-cta-eyebrow\">\u26a1 Get started<\/span>\n\t\t\t\t<h3>Register and buy credits to start creating.<\/h3>\n\t\t\t\t<p>Pick a plan for the best value, or grab a one-off top-up. Either way, you'll be making AI radio imaging within minutes.<\/p>\n\t\t\t<\/div>\n\t\t\t<div class=\"rj24ais-cta-actions\">\n\t\t\t\t<a href=\"https:\/\/radiojingles24.com\/ai-plans\/\" class=\"rj24ais-cta-btn rj24ais-cta-primary\">View plans &rarr;<\/a>\n\t\t\t\t\t\t\t\t\t<a href=\"https:\/\/radiojingles24.com\/de\/checkout\/?add-to-cart=116839\" class=\"rj24ais-cta-btn rj24ais-cta-secondary\">Or grab a one-off top-up<\/a>\n\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\n  <!-- TOOL TABS -->\n  <div class=\"ais-tabs\">\n    <div class=\"ais-tab active\" data-tool=\"advert\">\ud83c\udf99\ufe0f Advert<small>Live now<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"sweeper\">\ud83d\udce3 Sweeper<small>Live now<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"djdrop\">\ud83c\udfa7 DJ Drop<small>Live now<\/small><\/div>\n    <div class=\"ais-tab\" data-tool=\"podcast\">\ud83c\udfac Podcast Intro\/Outro<small>Live now<\/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=\"creations\">\ud83d\uddc2\ufe0f My Creations<small>Saved 90 days<\/small><\/div>\n  <\/div>\n\n  <!-- ADVERT PANEL -->\n  <div class=\"ais-panel\" id=\"ais-advert-panel\">\n\n    <div class=\"ais-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    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Pick the music vibe <span style=\"font-weight:600;font-size:12px;color:#94A3B8;\">(used if you add music)<\/span><\/div>\n      <div class=\"ais-step-sub\">If you add music in step 4, the AI composes a fresh backing track in this style.<\/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\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-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> Add music &amp; produce <span style=\"font-weight:600;font-size:12px;color:#94A3B8;\">(optional)<\/span><\/div>\n      <label class=\"ais-toggle\" id=\"ais-music-wrap\">\n        <input type=\"checkbox\" id=\"ais-music\"> <span>Add AI music behind my voice <b>(+3 credits)<\/b> \u2014 music ducks under the voice and fades out at the end.<\/span>\n      <\/label>\n      <div class=\"ais-musiclevel\" id=\"ais-musiclevel-wrap\" style=\"display:none;\">\n        <span class=\"ais-delivery-lab\">Music volume<\/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>\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 MP3 to your account for 90 days. For WAV quality, download 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    <div class=\"ais-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: drop a full stop where you want the voice to pause.<\/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    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Pick the effect<\/div>\n      <div class=\"ais-step-sub\">The effect plays at the start (and, with a sting, lands again at the end) for that polished radio feel.<\/div>\n      <div class=\"ais-genres\" id=\"sw-sfx\"><\/div>\n      <div class=\"ais-prompt-wrap\" id=\"sw-sfx-prompt-wrap\">\n        <input type=\"text\" id=\"sw-sfx-prompt\" class=\"ais-input\" placeholder=\"Describe the effect \u2014 e.g. 'deep boom then a fast whoosh'\">\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\">Search or filter by country and type, then tap \u25b6 to preview.<\/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> Add a music sting <span style=\"font-weight:600;font-size:12px;color:#94A3B8;\">(optional)<\/span><\/div>\n      <label class=\"ais-toggle\" id=\"sw-sting-wrap\">\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      <div class=\"ais-musiclevel\" id=\"sw-musiclevel-wrap\" style=\"display:none;\">\n        <span class=\"ais-delivery-lab\">Music volume<\/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>\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 MP3 to your account for 90 days. For WAV quality, download 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>Pick the effect<\/h4><p>Whoosh, stab, riser or impact \u2014 plus an optional music sting.<\/p><\/div>\n      <div class=\"ais-how-card\"><div class=\"ais-how-num\">3<\/div><h4>Keep it<\/h4><p>Download your royalty-free sweeper, 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    <div class=\"ais-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: drop a full stop where you want the voice to pause.<\/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    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">2<\/span> Pick the effect<\/div>\n      <div class=\"ais-step-sub\">A sound effect plays with your voice for that polished feel.<\/div>\n      <div class=\"ais-genres\" id=\"dj-sfx\"><\/div>\n      <div class=\"ais-prompt-wrap\" id=\"dj-sfx-prompt-wrap\">\n        <input type=\"text\" id=\"dj-sfx-prompt\" class=\"ais-input\" placeholder=\"Describe the effect \u2014 e.g. 'deep boom then a fast whoosh'\">\n      <\/div>\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-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> Add a music sting <span style=\"font-weight:600;font-size:12px;color:#94A3B8;\">(optional)<\/span><\/div>\n      <label class=\"ais-toggle\" id=\"dj-sting-wrap\">\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      <div class=\"ais-musiclevel\" id=\"dj-musiclevel-wrap\" style=\"display:none;\">\n        <span class=\"ais-delivery-lab\">Music volume<\/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>\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 MP3 to your account for 90 days. For WAV quality, download 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    <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\">\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    <div class=\"ais-step\">\n      <div class=\"ais-step-h\"><span class=\"ais-step-n\">3<\/span> Pick the music vibe<\/div>\n      <div class=\"ais-step-sub\">A <span id=\"pc-seclabel\">30<\/span>-second instrumental plays <span id=\"pc-when\">after your intro<\/span>.<\/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 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-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>\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 MP3 to your account for 90 days. For WAV quality, download 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 marks a beat pause in the delivery.\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=\"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.<\/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  <!-- 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  <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    <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=\"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  function el(id){ return document.getElementById(id); }\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  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    return text.replace(\/([^\\d])\\.(\\s+|$)\/g, '$1. <break time=\"0.45s\" \/> ').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 = a real beat pause. Hyphens 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  \/* ===== 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\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    var w = scriptWords();\n    var base = Math.max(1, Math.ceil((w||1)\/WPC));\n    return base + (st.withMusic ? MUSIC_COST : 0);\n  }\n  function refreshCost(){\n    var w = scriptWords();\n    var base = Math.max(1, Math.ceil((w||1)\/WPC));\n    var total = base + (st.withMusic ? MUSIC_COST : 0);\n    var txt = 'Cost: <b>'+total+' credit'+(total===1?'':'s')+'<\/b>';\n    txt += ' <span style=\"color:#94A3B8;\">('+base+' for ~'+w+' words'+(st.withMusic?' + '+MUSIC_COST+' for music':'')+')<\/span>';\n    var c=el('ais-cost'); if(c) c.innerHTML = txt;\n    \/\/ footer mirrors it when live\n    updateFooterText({ tool:'advert', cost: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   *\/\n  function 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.loggedIn) { f.textContent = 'Log in to start creating'; 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\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  \/**\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  \/\/ 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  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      if(!v.demo){ toast('No preview available for this voice.'); return; }\n      if(!d._a){ d._a=new Audio(v.demo); }\n      if(curA&&curA!==d._a){ curA.pause(); curA.currentTime=0; if(curB) curB.innerHTML='&#9658;'; }\n      if(d._a.paused){ d._a.play(); pb.innerHTML='&#10073;&#10073;'; curA=d._a; curB=pb; } else { d._a.pause(); pb.innerHTML='&#9658;'; }\n      d._a.onended=function(){ pb.innerHTML='&#9658;'; };\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  function mixVoiceAndMusic(voiceBuf, musicBuf){\n    var sr = 44100;\n    var voiceDur = voiceBuf ? voiceBuf.duration : 0;\n    var intro = 1.0;                       \/\/ music-only lead-in before the voice\n    var voiceStart = musicBuf ? intro : 0; \/\/ when the voice actually begins\n    \/\/ SAFETY MARGIN: decoded MP3 duration can read short, which caused the music to\n    \/\/ fade while the voice was still talking. We hold music at full level until a\n    \/\/ clear margin PAST the detected end. HARD RULE: the fade only ever happens at\n    \/\/ the very end of the rendered file, so it can never fade over speech.\n    var SAFETY = 1.2;\n    var voiceEnd = voiceStart + voiceDur + SAFETY; \/\/ treat the voice as ending a touch later\n    var fadeOut = 1.0;                     \/\/ fade length at the very end\n    var total = voiceEnd + fadeOut;        \/\/ container ends exactly when the fade ends\n    if (st.target && total < st.target) { total = st.target; }\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    \/\/ Music level setting: low \/ medium \/ high (intro bed + ducked-under-voice level).\n    var LEVELS = { low:{bed:0.35,duck:0.07}, medium:{bed:0.55,duck:0.11}, high:{bed:0.80,duck:0.15} };\n    var lvl = LEVELS[st.musicLevel] || LEVELS.medium;\n\n    if (musicBuf){\n      var mSrc = ctx.createBufferSource(); mSrc.buffer = musicBuf;\n      \/\/ Music is requested at the FULL advert length, so it should not need looping\n      \/\/ (looping was what caused the restart\/gap at ~30s). No loop here by design.\n      var mGain = ctx.createGain();\n      var duckStart = voiceStart - 0.25;          \/\/ duck just before the voice comes in\n      var DUCK = lvl.duck;                         \/\/ ducked level under the voice\n      var BED  = lvl.bed;                          \/\/ music-only level (the intro\/outro)\n      var fadeBegin = total - fadeOut;             \/\/ the ONE and only place music fades\n      mGain.gain.setValueAtTime(BED, 0);\n      mGain.gain.linearRampToValueAtTime(BED, Math.max(0.01, duckStart));   \/\/ full during the intro\n      mGain.gain.linearRampToValueAtTime(DUCK, voiceStart + 0.2);           \/\/ duck under the voice\n      \/\/ Stay ducked through ALL the speech (right up to where the fade begins).\n      \/\/ If we're padded to a target, lift back to bed after the voice for the outro.\n      if (total > voiceEnd + fadeOut + 0.2) {\n        mGain.gain.setValueAtTime(DUCK, voiceEnd);\n        mGain.gain.linearRampToValueAtTime(BED, voiceEnd + 0.5);            \/\/ outro lift (padded case only)\n        mGain.gain.linearRampToValueAtTime(BED, fadeBegin);\n      } else {\n        mGain.gain.setValueAtTime(DUCK, fadeBegin);                        \/\/ hold ducked all the way to the fade\n      }\n      mGain.gain.linearRampToValueAtTime(0.0, total);                       \/\/ fade out \u2014 only at the very end\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  function bufferToWavUrl(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(!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('Please log in to create your advert.','error'); 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 script = 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 = 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    st.withMusic = wantsMusic;\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    var creditsAfter = null;\n\n    \/\/ Voice is always generated. Music only when the toggle is on.\n    var jobs = [\n      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    if (st.withMusic){\n      var musicBody = JSON.stringify({ genre:st.genre, prompt:el('ais-prompt').value.trim(), seconds:seconds });\n      jobs.push(startMusic(musicBody, H));\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      st.lastUrl = voiceUrl; \/\/ store the raw voice URL for the produce upsell\n      var musicUrl = res[1];\n      return Promise.all([ voiceUrl?fetchBuffer(ctx,voiceUrl):null, musicUrl?fetchBuffer(ctx,musicUrl):null ]);\n    }).then(function(bufs){\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      \/* 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: 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      \/\/ 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    }).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    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='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  el('ais-create').addEventListener('click', create);\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', creations:'ais-creations-panel' };\n    var btns={ advert:'ais-create', sweeper:'sw-create', djdrop:'dj-create', podcast:'pc-create', sung:'sg-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==='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\n  \/* ===== Reusable SFX tool factory (used by Sweeper AND DJ Drop) ===== *\/\n  var SFX = [\n    { key:'whoosh', ico:'\ud83d\udca8', label:'Whoosh', desc:'Fast transition' },\n    { key:'stab',   ico:'\ud83c\udfba', label:'Stab',   desc:'Brassy hit' },\n    { key:'riser',  ico:'\ud83d\udcc8', label:'Riser',  desc:'Building sweep' },\n    { key:'glitch', ico:'\u26a1', label:'Glitch', desc:'Digital stutter' },\n    { key:'impact', ico:'\ud83d\udca5', label:'Impact', desc:'Deep boom' },\n    { key:'custom', ico:'\u2728', label:'Something else', desc:'Describe your own' },\n    \/* RJ24-EDIT (Nov 2026): 'None' lets the customer skip the opening SFX entirely\n       (dry voice, or voice + music sting only). Closing accent still fires for polish. *\/\n    { key:'none',   ico:'\u2205', label:'None',   desc:'No effect \u2014 dry or music-only' }\n  ];\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  \/* v1.6.0 (Jun 2026): added softac, mor, country to the BPM map.\n     Customer feedback was that the sting picker lacked mellower genres for\n     Soft AC \/ MOR stations \u2014 these come in slower (80, 85 BPM) than the\n     existing AC (100). Country sits in the middle at 105. *\/\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  \/* v1.6 complementary opening SFX layer mapping. Layer 2 sits underneath the\n     main AI SFX (lower gain, slight offset) to add weight\/variety without\n     clashing. Frequency-complementary pairings: high-energy sweeps get a\n     low-end impact layer underneath; sharp hits get a build-up riser. *\/\n  var SFX_LAYER2 = {\n    whoosh: 'impact', stab: 'riser', riser: 'impact',\n    glitch: 'impact', impact: 'riser', custom: 'impact'\n  };\n\n  function buildSfxTool(p, opts){\n    \/\/ p = id prefix ('sw' or 'dj'). opts.noun = 'sweeper' \/ 'DJ drop'.\n    var st = { voice:null, sfx:'whoosh', 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    \/\/ For sweepers \/ DJ drops we PREFER imaging voices (flagged by the backend \u2014\n    \/\/ ElevenLabs Advertisement & Entertainment use-cases), which are the punchy,\n    \/\/ promo-style reads. If there are too few imaging voices to be useful, we fall\n    \/\/ back to the broader list (minus the obviously-flat audiobook\/narrative ones).\n    var IMAGING = VOICES.filter(function(v){ return v.is_imaging; });\n    var PVOICES;\n    if (IMAGING.length >= 6) {\n      PVOICES = IMAGING;\n    } else {\n      PVOICES = VOICES.filter(function(v){ var u=(v.use||'').toLowerCase(); return u.indexOf('audiobook')===-1 && u.indexOf('meditat')===-1 && u.indexOf('conversational')===-1 && u.indexOf('narrative')===-1; });\n      if (PVOICES.length < 6) PVOICES = VOICES;\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 (floor 2), + music if a\n    \/\/ sting is added. Matches the server.\n    \/\/ RJ24-EDIT (Nov 2026): when sfx === 'none', skip the SFX credit. Floor stays at base\n    \/\/ (no longer max(2,\u2026) because there's no SFX to add).\n    function cost(){ var t=pe('script').value.trim(); var w=t?t.split(\/\\s+\/).filter(Boolean).length:0; var base=Math.max(1,Math.ceil((w||1)\/WPC)); var sfxC=(st.sfx==='none')?0:1; var floor=(st.sfx==='none')?base:Math.max(2, base+sfxC); return floor + (st.sting?MUSIC_COST:0); }\n    function refreshCost(){\n      var t=pe('script').value.trim(); var w=t?t.split(\/\\s+\/).filter(Boolean).length:0;\n      var base=Math.max(1,Math.ceil((w||1)\/WPC));\n      var sfxCredit = (st.sfx==='none') ? 0 : 1;\n      var floor = (st.sfx==='none') ? base : Math.max(2, base + sfxCredit);\n      var total = floor + (st.sting?MUSIC_COST:0);\n      var c=pe('cost');\n      if(c) {\n        var sfxText = (sfxCredit ? ' + 1 for the effect' : '');\n        c.innerHTML='Cost: <b>'+total+' credit'+(total===1?'':'s')+'<\/b> <span style=\"color:#94A3B8;\">('+base+' for ~'+w+' words'+sfxText+(st.sting?' + '+MUSIC_COST+' for music':'')+')<\/span>';\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: 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    \/\/ SFX picker\n    var sxWrap=pe('sfx');\n    SFX.forEach(function(g,i){\n      var d=document.createElement('div'); d.className='ais-genre'+(i===0?' selected':''); d.setAttribute('data-s',g.key);\n      d.innerHTML='<div class=\"ais-genre-ico\">'+g.ico+'<\/div><div class=\"ais-genre-name\">'+g.label+'<\/div><div class=\"ais-genre-desc\">'+g.desc+'<\/div>';\n      d.addEventListener('click', function(){\n        sxWrap.querySelectorAll('.ais-genre').forEach(function(x){x.classList.remove('selected');});\n        d.classList.add('selected');\n        st.sfx=g.key;\n        pe('sfx-prompt-wrap').classList.toggle('show', g.key==='custom');\n        refreshCost();\n      });\n      sxWrap.appendChild(d);\n    });\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(); if(!v.demo){ toast('No preview available for this voice.'); return; } if(!d._a){ d._a=new Audio(v.demo); } if(curA&&curA!==d._a){ curA.pause(); curA.currentTime=0; if(curB) curB.innerHTML='&#9658;'; } if(d._a.paused){ d._a.play(); pb.innerHTML='&#10073;&#10073;'; curA=d._a; curB=pb; } else { d._a.pause(); pb.innerHTML='&#9658;'; } d._a.onended=function(){ pb.innerHTML='&#9658;'; }; });\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(!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.sfx==='custom' && !pe('sfx-prompt').value.trim()){ toast('Describe the effect you want.','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='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      if(!DEMO){ var c=cost(); if(!CFG.loggedIn){ toast('Please log in to create.','error'); 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 line=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 jobs=[\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:(st.sfx==='none'), 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        \/* RJ24-EDIT (Nov 2026): SFX fetch is now graceful \u2014 if it fails the rest of the mix still ships,\n           the user just gets dry voice. Previously a single SFX network blip killed the whole job.\n           Duration cut from 2.5s to 1.0s: AI SFX models build\/release within the clip, and at 2.5s the\n           peak hit was often AFTER the voice started (so it was inaudible due to ducking). 1.0s puts\n           the peak in the first 0.4s, where the gain envelope still gives it real punch.\n           'none' skips the fetch entirely. *\/\n        (st.sfx==='none')\n          ? Promise.resolve(null)\n          : fetch(CFG.sfxUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ style:st.sfx, prompt:(pe('sfx-prompt')||{}).value||'', seconds:1.0 })}).then(function(r){ return r.json().then(function(j){ return r.ok?j:null; }); }).catch(function(){ return null; })\n      ];\n      if(st.sting){\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 }), H));\n      } else {\n        jobs.push(Promise.resolve(null));\n      }\n      \/* v1.6.1 (May 2026): closing accent SFX now respects 'None' SFX selection.\n         Previously it ALWAYS fired which surprised customers who picked None\n         expecting a dry, music-only sweeper. The UI promises \"No effect \u2014 dry or\n         music-only\", so we honour that promise here. When the customer picks any\n         OTHER SFX style, the closing accent still fires for polish. *\/\n      if (st.sfx !== 'none') {\n        jobs.push(\n          fetch(CFG.sfxUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ style:'impact', prompt:'short punchy radio imaging end accent hit', seconds:1.0 })}).then(function(r){ return r.json().then(function(j){ return r.ok?j:null; }); }).catch(function(){ return null; })\n        );\n      } else {\n        jobs.push(Promise.resolve(null));\n      }\n      \/* RJ24-EDIT (Nov 2026): for long scripts (10+ words), add a MID-script SFX hit.\n         This breaks up longer reads and gives more imaging energy. Same style as the\n         opening SFX so it sounds intentional. Skipped entirely if SFX is 'none'. *\/\n      var wordsForMid = (line.match(\/\\S+\/g)||[]).length;\n      var wantMidSfx = (wordsForMid >= 10 && st.sfx !== 'none');\n      if (wantMidSfx){\n        jobs.push(\n          fetch(CFG.sfxUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ style:st.sfx, prompt:(pe('sfx-prompt')||{}).value||'', seconds:0.8 })}).then(function(r){ return r.json().then(function(j){ return r.ok?j:null; }); }).catch(function(){ return null; })\n        );\n      } else {\n        jobs.push(Promise.resolve(null));\n      }\n      \/* v1.6: SFX LAYER 2 \u2014 opening. Complementary style to the user's chosen\n         opening SFX (whoosh\u2192impact, stab\u2192riser, etc). Shorter duration (0.6s)\n         to save credits while still landing the punch. Lower gain + offset in\n         the mix prevents clash with layer 1. Skipped when SFX is 'none'. *\/\n      if (st.sfx !== 'none') {\n        var layer2Style = SFX_LAYER2[st.sfx] || 'impact';\n        jobs.push(\n          fetch(CFG.sfxUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ style:layer2Style, prompt:'', seconds:0.6 })}).then(function(r){ return r.json().then(function(j){ return r.ok?j:null; }); }).catch(function(){ return null; })\n        );\n      } else {\n        jobs.push(Promise.resolve(null));\n      }\n      \/* v1.6: SFX LAYER 2 \u2014 closing. Deep sub-bass boom underneath the AI\n         closing accent. Custom prompt asks for sub-bass weight specifically\n         (the AI 'impact' style alone often lacks low-end). 0.6s = enough\n         decay for the boom to register without dragging past the closing\n         accent's tail. v1.6.1: also respects 'None' selection. *\/\n      if (st.sfx !== 'none') {\n        jobs.push(\n          fetch(CFG.sfxUrl,{method:'POST',credentials:'same-origin',headers:H,body:JSON.stringify({ style:'custom', prompt:'deep sub bass boom impact, weighty low end, short, for radio imaging', seconds:0.6 })}).then(function(r){ return r.json().then(function(j){ return r.ok?j:null; }); }).catch(function(){ return null; })\n        );\n      } else {\n        jobs.push(Promise.resolve(null));\n      }\n      Promise.all(jobs).then(function(res){ if(res[0]&&typeof res[0].credits!=='undefined') creditsAfter=res[0].credits; var vU=res[0]&&res[0].audioUrl, sU=res[1]&&res[1].audioUrl, mU=res[2], eU=res[3]&&res[3].audioUrl, midU=res[4]&&res[4].audioUrl, openL2U=res[5]&&res[5].audioUrl, endL2U=res[6]&&res[6].audioUrl;\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 = pe('script').value.trim();\n        st.lastGenre = st.sting ? (st.stingGenre || '') : '';\n        return Promise.all([ vU?fetchBuffer(ctx,vU):null, sU?fetchBuffer(ctx,sU):null, mU?fetchBuffer(ctx,mU):null, eU?fetchBuffer(ctx,eU):null, midU?fetchBuffer(ctx,midU):null, openL2U?fetchBuffer(ctx,openL2U):null, endL2U?fetchBuffer(ctx,endL2U):null ]); })\n        .then(function(bufs){ return mixSfxTool(bufs[0], bufs[1], bufs[2], st.musicLevel, bufs[3], bufs[4], bufs[5], bufs[6], st.sfx==='none'); })\n        .then(function(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          if(creditsAfter!==null){ CFG.credits=creditsAfter; updateFooterText({}); }\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 saveMeta = {\n              tool: toolKey,\n              script: pe('script').value.trim(),\n              voice_name: voiceObj.name || '',\n              voice_country: voiceObj.country || '',\n              sfx: st.sfx || '',\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    return { refreshCost:refreshCost, state:st };\n  }\n\n  \/\/ SFX-tool mix: opening SFX leads, voice punches in, sting ducks UNDER the voice,\n  \/\/ then sting rises and a closing SFX hits as it fades out \u2014 polished ending.\n  \/\/ RJ24-EDIT (Nov 2026): added midSfxBuf parameter for mid-script SFX hits on long scripts.\n  function mixSfxTool(voiceBuf, sfxBuf, stingBuf, level, endSfxBuf, midSfxBuf, openSfx2Buf, endSfx2Buf, noSfx){\n    \/* v1.6 (May 2026) \u2014 Layered AI SFX on top of v1.5.\n       New in v1.6:\n       - openSfx2Buf parameter: complementary AI SFX layered under opening hit\n       - endSfx2Buf parameter: deep sub-bass boom layered under closing hit\n       - BPM hint (auto-picked by genre) sent to ElevenLabs Music v2\n       Cost: +2 AI SFX calls per generation (~24 credits, ~$0.004 at Starter).\n       Everything from v1.5 kept intact below. *\/\n    \/* v1.5 (May 2026) \u2014 Tier 2 production polish on top of v1.4.\n       What's new vs v1.4:\n       1. SMILE EQ on master (low boost, mid scoop, presence boost, top air) \u2014\n          the broadcast frequency curve listeners' ears recognize.\n       2. MID-SIDE STEREO WIDENER on music bus (1.35x sides) \u2014 broadens the\n          stereo image while keeping the centre tight for vocal.\n       3. DE-ESSER (split-band) on voice \u2014 tames ElevenLabs sibilant harshness\n          at 6-8kHz without dulling overall presence.\n       4. SIDECHAIN DUCKING on music \u2014 driven by the voice envelope (not static\n          gain). Music breathes around the read instead of robotic ducking.\n       5. TRANSIENT DESIGNER on SFX hits \u2014 fast-attack low-threshold comp\n          that snaps the front of each hit. Punchier opening + closing.\n       6. SYNTHESISED LAYERS (sub-drop + impact) at opening and closing \u2014\n          free, deterministic DSP. Adds the weight that single AI SFX lack.\n       Kept from v1.4:\n       - Master saturation, parallel voice compression, voice reverb,\n         music-cuts-at-SFX-midpoint, music compressor. *\/\n    var sr = 44100;\n    var vDur = voiceBuf ? voiceBuf.duration : 0;\n\n    var LEVELS = {\n      low:    { bed: 0.45, duck: 0.17 },\n      medium: { bed: 0.65, duck: 0.27 },\n      high:   { bed: 0.85, duck: 0.38 }\n    };\n    var lv = LEVELS[level] || LEVELS.medium;\n\n    var lead   = Math.min(0.5, sfxBuf ? sfxBuf.duration * 0.4 : 0.2);\n    var vStart = lead;\n    var vEnd   = vStart + vDur;\n    var endDur = endSfxBuf ? endSfxBuf.duration : 0;\n    var endHit = vEnd;\n\n    \/\/ Music cuts at the impact midpoint of the closing SFX (kept from v1.4).\n    \/\/ v1.6.1: when noSfx, render a short trailing pad (0.25s) so the voice has\n    \/\/ a natural tail-out without a long silent end.\n    var musicCutAt = endSfxBuf\n      ? endHit + Math.min(endDur * 0.35, 0.22)\n      : vEnd + 0.15;\n    var total = noSfx\n      ? (vEnd + 0.25)\n      : (endSfxBuf ? (endHit + endDur + 0.1) : (vEnd + 0.35));\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    \/\/ ===== MASTER CHAIN: gain -> SMILE EQ -> saturation -> limiter -> destination =====\n    var master = ctx.createGain(); master.gain.value = 0.92;\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    eqAir.gain.value = 3.0;\n\n    var sat = ctx.createWaveShaper();\n    sat.curve = makeSaturationCurve(1.5);\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) {\n      var stS = ctx.createBufferSource();\n      stS.buffer = stingBuf;\n      if (stingBuf.duration < total) { stS.loop = true; stS.loopEnd = stingBuf.duration; }\n      var stG = ctx.createGain();\n\n      \/\/ Pre-voice: bed level, ramp to duck just before voice arrives\n      stG.gain.setValueAtTime(lv.bed, 0);\n      stG.gain.linearRampToValueAtTime(lv.bed, Math.max(0.01, vStart - 0.2));\n      stG.gain.linearRampToValueAtTime(lv.duck, vStart);\n\n      \/\/ v1.5 SIDECHAIN: voice envelope drives music duck depth during voice period\n      var rawEnv = computeEnvelope(voiceBuf, 25);\n      var sidechained = false;\n      if (rawEnv && rawEnv.length > 2 && vDur > 0.2) {\n        var env = smoothEnvelope(rawEnv);\n        \/\/ Find max for normalization\n        var envMax = 0.001;\n        for (var ei = 0; ei < env.length; ei++) {\n          if (env[ei] > envMax) envMax = env[ei];\n        }\n        \/\/ Build duck curve: louder voice = deeper duck (down to 0.5x lv.duck)\n        var duckCurve = new Float32Array(env.length);\n        var SIDECHAIN_AMT = 0.5;\n        for (var di = 0; di < env.length; di++) {\n          var norm = Math.min(1, env[di] \/ (envMax * 0.6));\n          duckCurve[di] = lv.duck * (1 - SIDECHAIN_AMT * norm);\n        }\n        try {\n          stG.gain.setValueCurveAtTime(duckCurve, vStart, vDur);\n          sidechained = true;\n        } catch(e) {\n          \/\/ Fallback if browser rejects curve (e.g. too short)\n        }\n      }\n      if (!sidechained) {\n        \/\/ Static duck fallback\n        stG.gain.setValueAtTime(lv.duck, Math.max(vStart + 0.05, vEnd - 0.05));\n      }\n\n      \/\/ Rise into closing SFX, then cut at SFX midpoint\n      stG.gain.linearRampToValueAtTime(lv.bed * 0.9, vEnd + 0.04);\n      stG.gain.setValueAtTime(lv.bed * 0.9, Math.max(vEnd + 0.04, musicCutAt - 0.04));\n      stG.gain.linearRampToValueAtTime(0.0, musicCutAt);\n\n      stS.connect(stG).connect(musicBus);\n\n      \/\/ Silence-skip on AI music\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){\n            musicOffset = Math.max(0, (ms \/ sr2) - 0.02);\n            break;\n          }\n        }\n      } catch(e){}\n      if (stS.loop && musicOffset > 0) { stS.loopStart = musicOffset; }\n      stS.start(0, musicOffset);\n    }\n\n    \/\/ ---- Opening SFX with v1.5 TRANSIENT DESIGNER ----\n    if (sfxBuf) {\n      var sS = ctx.createBufferSource();\n      sS.buffer = sfxBuf;\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.6 OPENING AI LAYER 2 (complementary style, lower level, slight offset) ----\n    \/\/ Sits underneath the main opening SFX to add variety + weight. Lower gain\n    \/\/ (0.55) so it supports rather than competes with the main hit. 60ms offset\n    \/\/ makes the two hits feel like one layered transition rather than two events.\n    if (openSfx2Buf) {\n      var s2S = ctx.createBufferSource();\n      s2S.buffer = openSfx2Buf;\n      var s2TD = ctx.createDynamicsCompressor();\n      s2TD.threshold.value = -12.0;\n      s2TD.knee.value      = 0.0;\n      s2TD.ratio.value     = 6.0;\n      s2TD.attack.value    = 0.001;\n      s2TD.release.value   = 0.05;\n      var s2G = ctx.createGain();\n      var s2Start = 0.06; \/\/ 60ms offset behind primary\n      s2G.gain.setValueAtTime(0.55, s2Start);\n      s2G.gain.linearRampToValueAtTime(0.55, Math.max(s2Start + 0.05, vStart - 0.05));\n      s2G.gain.linearRampToValueAtTime(0.18, vStart + 0.15);\n      s2G.gain.linearRampToValueAtTime(0.0,  vStart + 0.40);\n      s2S.connect(s2TD).connect(s2G).connect(master);\n      s2S.start(s2Start);\n    }\n\n    \/\/ ---- v1.5 OPENING LAYERS: synthesised sub-drop + impact (FREE, no API) ----\n    \/\/ v1.6.1: skip when the user picked 'None' SFX \u2014 synth layers were designed\n    \/\/ to ADD weight to AI SFX; on their own they sound like an unwanted 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    \/\/ ---- Mid-script SFX ----\n    if (midSfxBuf && voiceBuf) {\n      var mid = vStart + (vDur * 0.55);\n      var mS = ctx.createBufferSource();\n      mS.buffer = midSfxBuf;\n      var mG = ctx.createGain();\n      mG.gain.setValueAtTime(0.0, mid - 0.02);\n      mG.gain.linearRampToValueAtTime(0.55, mid + 0.02);\n      mG.gain.linearRampToValueAtTime(0.0, mid + Math.min(midSfxBuf.duration, 0.7));\n      mS.connect(mG).connect(master);\n      mS.start(mid);\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    \/\/ ---- Closing SFX accent with v1.5 TRANSIENT DESIGNER + layers ----\n    if (endSfxBuf) {\n      var eS = ctx.createBufferSource(); eS.buffer = endSfxBuf;\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(); eG.gain.value = 0.95;\n      eS.connect(eTD).connect(eG).connect(master);\n      eS.start(endHit);\n    }\n\n    \/\/ ---- v1.6 CLOSING AI LAYER 2 (deep sub-bass boom, slight offset) ----\n    \/\/ Adds low-end weight underneath the AI closing impact. The 'custom' prompt\n    \/\/ requested specifically asks for sub-bass weight (the stock 'impact' style\n    \/\/ is mid-focused). 40ms offset behind the primary so the two hits feel\n    \/\/ like one large layered impact.\n    if (endSfx2Buf) {\n      var e2S = ctx.createBufferSource();\n      e2S.buffer = endSfx2Buf;\n      var e2TD = ctx.createDynamicsCompressor();\n      e2TD.threshold.value = -8.0;\n      e2TD.knee.value      = 0.0;\n      e2TD.ratio.value     = 6.0;\n      e2TD.attack.value    = 0.001;\n      e2TD.release.value   = 0.04;\n      var e2G = ctx.createGain(); e2G.gain.value = 0.70;\n      e2S.connect(e2TD).connect(e2G).connect(master);\n      e2S.start(endHit + 0.04);\n    }\n\n    \/\/ v1.5: synthesised closing layers fire even if AI closing failed (safety net).\n    \/\/ v1.6.1: skip when the user picked 'None' SFX \u2014 the user explicitly asked\n    \/\/ for a dry sweeper, so we don't add an unrequested closing thud.\n    if (!noSfx) {\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(endHit);\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(endHit);\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  \/* ===================== 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  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    \/\/ Podcast intro\/outro always includes a music bed, so it's priced like an\n    \/\/ advert-with-music: word cost + the music surcharge (min ~4 credits).\n    var t=el('pc-script').value.trim();\n    var w=t?t.split(\/\\s+\/).filter(Boolean).length:0;\n    var base=Math.max(1,Math.ceil((w||1)\/WPC));\n    return base + MUSIC_COST;\n  }\n  function pcRefresh(){ 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; el('pc-count').innerHTML=w+' word'+(w===1?'':'s')+' &middot; \u2248 '+s+' sec voice'; var base=Math.max(1,Math.ceil((w||1)\/WPC)); var total=base+MUSIC_COST; var c=el('pc-cost'); if(c) c.innerHTML='Cost: <b>'+total+' credit'+(total===1?'':'s')+'<\/b> <span style=\"color:#94A3B8;\">('+base+' for ~'+w+' words + '+MUSIC_COST+' for music)<\/span>'; 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: total }); } }\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(); if(!v.demo){ toast('No preview available for this voice.'); return; } if(!d._a){ d._a=new Audio(v.demo); } if(pcCurA&&pcCurA!==d._a){ pcCurA.pause(); pcCurA.currentTime=0; if(pcCurB) pcCurB.innerHTML='&#9658;'; } if(d._a.paused){ d._a.play(); pb.innerHTML='&#10073;&#10073;'; pcCurA=d._a; pcCurB=pb; } else { d._a.pause(); pb.innerHTML='&#9658;'; } d._a.onended=function(){ pb.innerHTML='&#9658;'; }; }); 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  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='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(!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('Please log in to create.','error'); 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 script=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 = 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    Promise.all([\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', 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      startMusic(JSON.stringify({ genre:pc.genre, prompt:el('pc-prompt').value.trim(), seconds:musicSecs }), H)\n    ]).then(function(res){ if(res[0]&&typeof res[0].credits!=='undefined') creditsAfter=res[0].credits;\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 = 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){ return mixPodcast(bufs[0], bufs[1], pc.mode, pc.musicLevel, PC_SECS); })\n      .then(function(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        if(creditsAfter!==null){ CFG.credits=creditsAfter; updateFooterText({}); }\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: 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  \/\/ Podcast mix \u2014 full music piece (NOT looped), positioned by mode.\n  function mixPodcast(voiceBuf, musicBuf, mode, level, tailSecs){\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\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 preRoll = 0.25;\n      var vStart  = preRoll;\n      var vEnd    = vStart + vDur;\n      total       = vEnd + TAIL + 0.1;\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        mG.gain.setValueAtTime(lv.bed, 0);\n        mG.gain.linearRampToValueAtTime(lv.bed, Math.max(0.01, vStart - 0.05));\n        mG.gain.linearRampToValueAtTime(lv.duck, vStart + 0.2);\n        mG.gain.setValueAtTime(lv.duck, vEnd);\n        mG.gain.linearRampToValueAtTime(lv.bed, vEnd + 0.6);\n        mG.gain.setValueAtTime(lv.bed, total - fade);\n        mG.gain.linearRampToValueAtTime(0.0, total);\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      var mIn = 1.5;\n      var vStart2 = TAIL;\n      var vEnd2   = vStart2 + vDur;\n      total = vEnd2 + fade + 0.1;\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        mG2.gain.setValueAtTime(0.0, 0);\n        mG2.gain.linearRampToValueAtTime(lv.bed, mIn);\n        mG2.gain.setValueAtTime(lv.bed, vStart2 - 0.3);\n        mG2.gain.linearRampToValueAtTime(lv.duck, vStart2 + 0.2);\n        mG2.gain.setValueAtTime(lv.duck, vEnd2);\n        mG2.gain.linearRampToValueAtTime(0.0, vEnd2 + fade);\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.';\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      if(!CFG.loggedIn){ toast('Please log in to create.','error'); 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\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);\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    function pollSungBatch(taskIds, inner, btn){\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              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            } 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      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 account (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'\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) {\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<\/script>\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 v27.7 - 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\/de\/ai-studio\/\" \/>\n<meta property=\"og:locale\" content=\"de_DE\" \/>\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\/de\/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-06-03T10:47:35+00:00\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:site\" content=\"@radiojingles24\" \/>\n<meta name=\"twitter:label1\" content=\"Gesch\u00e4tzte Lesezeit\" \/>\n\t<meta name=\"twitter:data1\" content=\"148\u00a0Minuten\" \/>\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-06-03T10:47:35+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\":\"de\",\"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\":\"de\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/radiojingles24.com\\\/#organization\",\"name\":\"Radio Jingles 24\",\"url\":\"https:\\\/\\\/radiojingles24.com\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"de\",\"@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 | Podcast Intros | Gesungene Jingles","description":"Radio Jingles, Sung Jingles, DJ Drops und Podcast Intros f\u00fcr kommerzielle, Community- und Internetradiosender. In Gro\u00dfbritannien ans\u00e4ssig. Preise ab \u00a3 5. Online bestellen.","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\/de\/ai-studio\/","og_locale":"de_DE","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\/de\/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-06-03T10:47:35+00:00","twitter_card":"summary_large_image","twitter_site":"@radiojingles24","twitter_misc":{"Gesch\u00e4tzte Lesezeit":"148\u00a0Minuten"},"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 | Podcast Intros | Gesungene Jingles","isPartOf":{"@id":"https:\/\/radiojingles24.com\/#website"},"datePublished":"2026-05-27T12:06:42+00:00","dateModified":"2026-06-03T10:47:35+00:00","description":"Radio Jingles, Sung Jingles, DJ Drops und Podcast Intros f\u00fcr kommerzielle, Community- und Internetradiosender. In Gro\u00dfbritannien ans\u00e4ssig. Preise ab \u00a3 5. Online bestellen.","breadcrumb":{"@id":"https:\/\/radiojingles24.com\/ai-studio\/#breadcrumb"},"inLanguage":"de","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 | Gesungene Jingles","description":"Radio Jingles, Sung Jingles, DJ Drops und Podcast Intros f\u00fcr kommerzielle, Community- und Internetradiosender. In Gro\u00dfbritannien ans\u00e4ssig. Preise ab \u00a3 5. Online bestellen.","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":"de"},{"@type":"Organization","@id":"https:\/\/radiojingles24.com\/#organization","name":"Radio Jingles 24","url":"https:\/\/radiojingles24.com\/","logo":{"@type":"ImageObject","inLanguage":"de","@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\/de\/wp-json\/wp\/v2\/pages\/116853","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/radiojingles24.com\/de\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/radiojingles24.com\/de\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/radiojingles24.com\/de\/wp-json\/wp\/v2\/users\/104"}],"replies":[{"embeddable":true,"href":"https:\/\/radiojingles24.com\/de\/wp-json\/wp\/v2\/comments?post=116853"}],"version-history":[{"count":49,"href":"https:\/\/radiojingles24.com\/de\/wp-json\/wp\/v2\/pages\/116853\/revisions"}],"predecessor-version":[{"id":117114,"href":"https:\/\/radiojingles24.com\/de\/wp-json\/wp\/v2\/pages\/116853\/revisions\/117114"}],"wp:attachment":[{"href":"https:\/\/radiojingles24.com\/de\/wp-json\/wp\/v2\/media?parent=116853"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}