Hamed744 commited on
Commit
b767545
·
verified ·
1 Parent(s): 8ea59e0

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +452 -357
index.html CHANGED
@@ -3,7 +3,8 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Alpha TTS - نسل جدید تبدیل متن به صدا</title>
 
7
  <style>
8
  @import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800;900&display=swap');
9
 
@@ -20,6 +21,7 @@
20
  --accent-secondary-hover: #059669;
21
  --input-bg: #F8FAFC;
22
  --input-border-focus: var(--accent-primary);
 
23
 
24
  --radius-card: 24px;
25
  --radius-input: 14px;
@@ -30,9 +32,9 @@
30
  --transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
31
 
32
  /* Custom Audio Player Colors */
33
- --waveform-color-active: #3B82F6; /* Blue for active part of waveform */
34
- --waveform-color-inactive: #D0D9E6; /* Lighter shade for inactive part of waveform */
35
- --waveform-dashed-line-color: #E0E4E9; /* Very light grey for the dashed line */
36
  }
37
 
38
  @keyframes fadeInDown {
@@ -51,12 +53,23 @@
51
  from { opacity: 1; transform: scale(1) translateY(0); }
52
  to { opacity: 0; transform: scale(0.8) translateY(20px); }
53
  }
54
-
55
- /* Keyframe for loading spinner in button */
56
  @keyframes spin {
57
  from { transform: rotate(0deg); }
58
  to { transform: rotate(360deg); }
59
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  body {
62
  font-family: var(--app-font);
@@ -87,6 +100,7 @@
87
  text-align: center;
88
  margin-bottom: 1.5rem;
89
  animation: fadeInDown 0.8s 0.1s ease-out backwards;
 
90
  }
91
  .app-header h1 {
92
  font-size: 2.1em;
@@ -105,6 +119,36 @@
105
  font-weight: 400;
106
  line-height: 1.6;
107
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  .main-content {
110
  padding: 2.5rem;
@@ -189,6 +233,7 @@
189
  padding: 0 0.2rem;
190
  }
191
  #char-count { font-weight: 600; color: var(--accent-primary); }
 
192
 
193
 
194
  /* --- نمایش گوینده منتخب --- */
@@ -205,6 +250,8 @@
205
  position: relative;
206
  margin-bottom: 1.2rem;
207
  cursor: pointer;
 
 
208
  }
209
  #selected-speaker-card:hover {
210
  transform: translateY(-6px) scale(1.03);
@@ -272,6 +319,8 @@
272
  padding: 2rem;
273
  border-radius: var(--radius-card);
274
  width: 90%;
 
 
275
  box-shadow: var(--shadow-strong);
276
  border: 1px solid var(--panel-border);
277
  opacity: 0; /* For animation */
@@ -295,7 +344,6 @@
295
  /* --- مودال گالری گویندگان (اختصاصی) --- */
296
  #speaker-modal .modal-dialog {
297
  max-width: 700px;
298
- max-height: 85vh; overflow-y: auto;
299
  }
300
  #speaker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 1.2rem; }
301
  @media (min-width: 640px) { #speaker-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } }
@@ -452,11 +500,16 @@
452
  transform: none;
453
  }
454
  #generate-btn:disabled::before { display: none; }
455
- #generate-btn svg {
 
 
 
 
456
  display:inline-block; margin-left: 0.5em; width:1.2em; height:1.2em;
457
- vertical-align: middle;
458
  }
459
 
 
460
  /* --- Output Section (now the card container for status/loading/player) --- */
461
  #output-section {
462
  margin-top: 3rem;
@@ -486,7 +539,14 @@
486
  display: none !important;
487
  }
488
 
489
- #status-message { font-weight: 500; color: var(--text-secondary); text-align: center; font-size: 1.1em; }
 
 
 
 
 
 
 
490
 
491
 
492
  /* --- انیمیشن پردازش --- */
@@ -558,40 +618,40 @@
558
  box-sizing: border-box;
559
  flex-direction: column;
560
  gap: 1.2rem;
 
561
  }
562
 
563
  .audio-waveform-container {
564
  display: flex;
565
  align-items: center;
566
- gap: 1rem; /* Space between time labels and waveform */
567
  width: 100%;
568
  margin-bottom: 1rem;
569
  }
570
  .audio-time {
571
  font-size: 0.9em;
572
  color: var(--text-secondary);
573
- min-width: 40px; /* Consistent width for time labels */
574
  text-align: center;
575
- font-variant-numeric: tabular-nums; /* Ensures numbers align vertically */
576
- user-select: none; /* Prevent selection of time text */
577
  }
578
 
579
- /* Waveform Display (Canvas now) */
580
  .audio-waveform {
581
  flex-grow: 1;
582
- height: 60px; /* Fixed height for the waveform area */
583
  position: relative;
584
- overflow: hidden; /* To contain dashed line */
585
- display: flex; /* Flex context for canvas alignment */
586
- align-items: center; /* Vertically center canvas */
587
- justify-content: center; /* Horizontally center canvas */
588
- margin-bottom: 0.5rem; /* Slightly less space from controls for compact feel */
589
  }
590
 
591
  #audio-waveform-canvas {
592
- display: block; /* Remove extra space below canvas */
593
- max-width: 100%; /* Ensure canvas doesn't overflow */
594
- height: 100%; /* Canvas takes full height of its container */
595
  }
596
 
597
  .audio-waveform-dashed-line {
@@ -604,7 +664,7 @@
604
  background-position: center;
605
  background-size: 10px 1px;
606
  transform: translateY(-50%);
607
- z-index: 1; /* Above the canvas */
608
  }
609
 
610
  /* Audio Controls */
@@ -612,18 +672,18 @@
612
  display: flex;
613
  justify-content: center;
614
  align-items: center;
615
- gap: 1.5rem; /* Space between play/skip buttons */
616
- margin-bottom: 1rem; /* Space between main controls and utility controls */
617
  }
618
  .audio-skip-btn, .audio-play-pause-btn-large, .audio-volume-btn, .audio-speed-btn {
619
  background: none;
620
  border: none;
621
  cursor: pointer;
622
- padding: 8px; /* Default padding for clickable area */
623
  transition: transform 0.2s, opacity 0.2s;
624
  }
625
  .audio-skip-btn, .audio-play-pause-btn-large, .audio-volume-btn {
626
- color: var(--text-secondary); /* Grey for icons */
627
  }
628
  .audio-skip-btn:hover, .audio-play-pause-btn-large:hover, .audio-volume-btn:hover {
629
  opacity: 0.8;
@@ -637,9 +697,17 @@
637
  fill: currentColor;
638
  }
639
  .audio-play-pause-btn-large {
640
- padding: 0; /* Remove padding for this button to control size via width/height */
641
  width: 50px;
642
  height: 50px;
 
 
 
 
 
 
 
 
643
  }
644
  .audio-play-pause-btn-large svg {
645
  width: 38px;
@@ -650,7 +718,7 @@
650
  .audio-utility-controls {
651
  display: flex;
652
  align-items: center;
653
- justify-content: space-between; /* Volume left, Speed right */
654
  width: 100%;
655
  }
656
  .audio-volume-btn svg {
@@ -669,12 +737,14 @@
669
  text-align: center;
670
  color: var(--text-primary);
671
  box-shadow: var(--shadow-subtle);
 
672
  }
673
  .audio-speed-btn:hover {
674
  background-color: var(--input-bg);
 
 
675
  }
676
-
677
- /* Hide the default audio player */
678
  #hidden-audio-player { display: none !important; }
679
 
680
  /* Responsive adjustments for mobile */
@@ -690,13 +760,13 @@
690
  }
691
  #audio-player-content {
692
  padding: 1rem;
693
- gap: 0.8rem; /* Reduce gap inside player */
694
  }
695
  .audio-waveform-container {
696
- gap: 0.5rem; /* Reduce gap around waveform */
697
  }
698
  .audio-time {
699
- font-size: 0.85em; /* Smaller time font */
700
  min-width: 35px;
701
  }
702
  .audio-skip-btn svg {
@@ -712,7 +782,7 @@
712
  height: 32px;
713
  }
714
  .audio-controls-group {
715
- gap: 1rem; /* Reduce gap between play/skip */
716
  }
717
  .audio-volume-btn svg {
718
  width: 20px;
@@ -722,35 +792,55 @@
722
  font-size: 0.8em;
723
  padding: 5px 10px;
724
  }
 
 
 
 
 
 
 
 
 
 
 
 
725
  }
726
  </style>
727
  </head>
728
  <body>
729
  <div class="container">
730
  <header class="app-header">
731
- <h1>تبدیل متن به صدا با هوش مصنوعی آلفا</h1>
732
- <p>صدایی نو، تجربه‌ای نوین در تبدیل متن به گفتار با هوش مصنوعی پیشرفته</p>
 
 
 
 
 
 
733
  </header>
734
 
735
  <main class="main-content">
736
  <form id="tts-form">
737
  <div class="form-group">
738
  <label for="text-input">📝 متن مورد نظر شما</label>
739
- <textarea id="text-input" rows="4" placeholder="متن خود را اینجا وارد کنید..."></textarea>
740
  <div class="char-counter-wrapper">
741
- <span id="char-count">0</span> / <span id="char-max">50000</span> نویسه
 
742
  </div>
743
  </div>
744
  <div class="form-group">
745
  <label for="prompt-input">🗣️ توصیف سبک و لحن گفتار (اختیاری)</label>
746
- <input type="text" id="prompt-input" placeholder="مثال: با لحنی رسمی و موقر، یا شاد و کودکانه">
 
747
  </div>
748
 
749
  <div class="form-group">
750
  <label>🎤 انتخاب گوینده حرفه‌ای</label>
751
  <div id="selected-speaker-display">
752
- <div id="selected-speaker-card" title="برای تغییر گوینده کلیک کنید">
753
- <img id="selected-speaker-img" src="" alt="عکس گوینده">
754
  <div id="selected-speaker-info">
755
  <h3 id="selected-speaker-name"></h3>
756
  <p id="selected-speaker-desc">گوینده پیش‌فرض</p>
@@ -768,19 +858,21 @@
768
  <div class="form-group">
769
  <div class="label-with-info">
770
  <label for="temperature-slider">🌡️ میزان خلاقیت و نوآوری صدا</label>
771
- <div class="info-icon" id="temp-info-icon" role="button" tabindex="0" aria-label="اطلاعات بیشتر">!
772
  </div>
773
  </div>
774
  <div class="slider-container">
775
- <input type="range" id="temperature-slider" min="0.1" max="1.5" step="0.05" value="0.9">
776
  <span id="temperature-value">0.9</span>
777
  </div>
778
  </div>
779
 
780
- <button type="submit" id="generate-btn">✨ تولید صدا با آلفا</button>
 
 
781
  </form>
782
 
783
- <div id="output-section">
784
  <div id="status-message">صدای تولید شده در اینجا نمایش داده خواهد شد.</div>
785
  <div id="loading-animation-wrapper">
786
  <div class="orbital-loader">
@@ -795,7 +887,7 @@
795
  <div id="audio-player-content">
796
  <div class="audio-waveform-container">
797
  <span class="audio-time audio-current-time">0:00</span>
798
- <div class="audio-waveform">
799
  <canvas id="audio-waveform-canvas"></canvas>
800
  <div class="audio-waveform-dashed-line"></div>
801
  </div>
@@ -803,27 +895,27 @@
803
  </div>
804
 
805
  <div class="audio-controls-group">
806
- <button class="audio-skip-btn backward">
807
- <svg viewBox="0 0 24 24"><path d="M11 16V8l-4 4 4 4zm4-12v16l7-8-7-8z"></path></svg>
808
  </button>
809
- <button class="audio-play-pause-btn-large">
810
  <svg viewBox="0 0 24 24" class="play-icon"><path d="M8 5v14l11-7z"></path></svg>
811
  <svg viewBox="0 0 24 24" class="pause-icon" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg>
812
  </button>
813
- <button class="audio-skip-btn forward">
814
- <svg viewBox="0 0 24 24"><path d="M13 16V8l4 4-4 4zM9 4v16L2 12l7-8z"></path></svg>
815
  </button>
816
  </div>
817
 
818
  <div class="audio-utility-controls">
819
- <button class="audio-volume-btn">
820
  <svg viewBox="0 0 24 24" class="volume-high-icon"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path></svg>
821
  <svg viewBox="0 0 24 24" class="volume-mute-icon" style="display:none;"><path d="M7 9v6h4l5 5V4L11 9H7zM16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zM19 12c0 .94-.23 1.82-.68 2.6L19 14.88c.45-.88.7-1.88.7-2.88 0-4.01-2.99-7.14-7-8.05v2.06c2.89.86 5 3.54 5 6.71zM4.55 4L2 6.55 9.45 14H7v6h4l5 5V14.55l4.05 4.05L22 18 12 8 4.55 4z"></path></svg>
822
  </button>
823
- <button class="audio-speed-btn">1x</button>
824
  </div>
825
  </div>
826
- <audio id="hidden-audio-player" style="display: none;"></audio>
827
  </div>
828
  </main>
829
  </div>
@@ -832,8 +924,8 @@
832
  <div id="speaker-modal" class="modal-overlay">
833
  <div class="modal-dialog">
834
  <div class="modal-header">
835
- <h2>گالری گویندگان آلفا نوا</h2>
836
- <button type="button" class="close-modal-btn" data-modal-id="speaker-modal" aria-label="بستن مودال">×</button>
837
  </div>
838
  <div id="speaker-grid">
839
  <!-- Speaker cards will be generated here by JS -->
@@ -850,8 +942,8 @@
850
  </div>
851
  <div id="info-modal-content">
852
  <p>این تنظیم مشخص می‌کند که هوش مصنوعی تا چه حد در تولید صدا <strong>خلاقیت</strong> به خرج دهد.</p>
853
- <p><strong>مقادیر بالاتر:</strong> منجر به صدایی متنوع‌تر، پویاتر و گاهی اوقات غیرمنتظره‌تر می‌شود. مناسب برای زمانی که به دنبال لحنی خاص و منحصربه‌فرد هستید.</p>
854
- <p><strong>مقادیر پایین‌تر:</strong> صدایی پایدارتر، قابل پیش‌بینی‌تر و نزدیک‌تر به صدای استاندارد گوینده تولید می‌کند. مناسب برای خوانش متون رسمی یا زمانی که ثبات لحن اهمیت دارد.</p>
855
  <span class="range-info">محدوده پیشنهادی: ۰.۱ (پایدار) تا ۱.۵ (بسیار خلاق)</span>
856
  </div>
857
  </div>
@@ -862,43 +954,39 @@
862
 
863
  <script>
864
  document.addEventListener('DOMContentLoaded', () => {
865
- const HF_SPACE_URL = "https://hamed744-ttspro.hf.space";
866
- const JOIN_QUEUE_URL = `${HF_SPACE_URL}/gradio_api/queue/join`;
867
- const GET_DATA_URL_BASE = `${HF_SPACE_URL}/gradio_api/queue/data`;
868
- const FILE_URL_BASE = `${HF_SPACE_URL}/gradio_api/file=`;
869
- const FN_INDEX = 1;
870
-
871
  const speakers = [
872
- { id: "Charon", name: "شهاب (مرد)", desc: "صدایی قدرتمند و رسا" },
873
- { id: "Zephyr", name: "آوا (زن)", desc: "لطیف و دلنشین" },
874
- { id: "Achird", name: "نوید (مرد)", desc: "جوان و پرانرژی" },
875
- { id: "Zubenelgenubi", name: "رویا (زن)", desc: "گرم و صمیمی" },
876
- { id: "Vindemiatrix", name: "کیان (مرد)", desc: "باوقار و رسمی" },
877
- { id: "Sadachbia", name: "پریسا (زن)", desc: "شاداب و پویا" },
878
- { id: "Sadaltager", name: "آرش (مرد)", desc: "مطمئن و تاثیرگذار" },
879
- { id: "Sulafat", name: "شبنم (زن)", desc: "آرام و متین" },
880
- { id: "Laomedeia", name: "سهیل (مرد)", desc: "دوستانه و گیرا" },
881
- { id: "Achernar", name: "مریم (زن)", desc: "حرفه‌ای و واضح" },
882
- { id: "Alnilam", name: "بهرام (مرد)", desc: "حماسی و نافذ" },
883
- { id: "Schedar", name: "نگار (زن)", desc: "مهربان و شیرین" },
884
- { id: "Gacrux", name: "فرید (مرد)", desc: "پخته و قابل اعتماد" },
885
- { id: "Pulcherrima", name: "سارا (زن)", desc: "جذاب و مدرن" },
886
- { id: "Umbriel", name: "مانی (مرد)", desc: "خلاق و متفاوت" },
887
- { id: "Algieba", name: "آناهیتا (زن)", desc: "با اصالت و شیک" },
888
- { id: "Despina", name: "دلنواز (زن)", desc: "هنری و احساسی" },
889
- { id: "Erinome", name: "رسا (مرد)", desc: "شفاف و گویا" },
890
- { id: "Algenib", name: "امید (مرد)", desc: "انگیزه بخش و مثبت" },
891
- { id: "Rasalthgeti", name: "الهه (زن)", desc: "اسرارآمیز و فریبنده" },
892
- { id: "Orus", name: "بردیا (مرد)", desc: "ورزشی و پرهیجان" },
893
- { id: "Aoede", name: "ترانه (زن)", desc: "موزیکال و خوش‌آهنگ" },
894
- { id: "Callirrhoe", name: "نیما (مرد)", desc: "روایتگر و قصه‌گو" },
895
- { id: "Autonoe", name: "هستی (زن)", "desc": "طبیعی و خودمانی" },
896
- { id: "Enceladus", name: "کامیار (مرد)", desc: "مصمم و جدی" },
897
- { id: "Iapetus", name: "ستاره (زن)", desc: "درخشان و گیرا" },
898
- { id: "Puck", name: "پویا (مرد)", desc: "بازیگوش و سرزنده" },
899
- { id: "Kore", name: "مهتاب (زن)", desc: "نجواگر و آرامش‌بخش" },
900
- { id: "Fenrir", name: "سام (مرد)", desc: "جسور و بی‌باک" },
901
- { id: "Leda", name: "لیدا (زن)", desc: "کلاسیک و باوقار" }
902
  ];
903
 
904
  const form = document.getElementById('tts-form');
@@ -907,6 +995,7 @@
907
  const tempSlider = document.getElementById('temperature-slider');
908
  const tempValueSpan = document.getElementById('temperature-value');
909
  const generateBtn = document.getElementById('generate-btn');
 
910
 
911
  const outputSection = document.getElementById('output-section');
912
  const statusMessage = document.getElementById('status-message');
@@ -942,46 +1031,49 @@
942
  const volumeMuteIcon = volumeBtn.querySelector('.volume-mute-icon');
943
  const speedBtn = audioPlayerContent.querySelector('.audio-speed-btn');
944
 
945
- let currentPlaybackSpeedIndex = 0;
946
  const playbackSpeeds = [1.0, 1.25, 1.5, 0.75]; // Adjusted speeds as common values
947
-
948
  let audioPeaks = []; // Stores calculated peaks for waveform
949
- let audioContext = null; // Will be initialized when needed
950
-
951
 
952
  // Character Counter
953
  const charCountSpan = document.getElementById('char-count');
954
  const charMaxSpan = document.getElementById('char-max');
955
- const MAX_CHARS = 50000;
956
  charMaxSpan.textContent = MAX_CHARS.toLocaleString('fa-IR');
957
 
958
- textInput.addEventListener('input', () => {
959
- const currentLength = textInput.value.length;
960
- charCountSpan.textContent = currentLength.toLocaleString('fa-IR');
961
- if (currentLength > MAX_CHARS) {
962
- charCountSpan.style.color = 'var(--accent-secondary-hover)';
963
- } else {
964
- charCountSpan.style.color = 'var(--accent-primary)';
965
- }
966
- });
 
 
 
 
 
967
 
968
  function getSpeakerById(id) {
969
  return speakers.find(s => s.id === id) || speakers[0];
970
  }
971
 
972
- function getImageUrl(speaker, index, size = 'thumb') {
973
- const gender = speaker.name.includes('(مرد)') ? 'men' : 'women';
974
- const imageSeed = speaker.id.charCodeAt(0) + speaker.id.length + index;
975
- const imageIndex = (imageSeed * 7 + 5) % 100;
976
- let portraitSizePath = 'thumb/';
977
- if (size === 'large') portraitSizePath = '';
978
- return `https://randomuser.me/api/portraits/${portraitSizePath}${gender}/${imageIndex}.jpg`;
979
  }
980
 
981
  function updateSelectedSpeakerDisplay(speakerId) {
982
  const speaker = getSpeakerById(speakerId);
983
- const speakerIndex = speakers.findIndex(s => s.id === speaker.id);
984
- selectedSpeakerImgDisplay.src = getImageUrl(speaker, speakerIndex, 'large');
985
  selectedSpeakerImgDisplay.alt = `عکس گوینده ${speaker.name}`;
986
  selectedSpeakerNameDisplay.textContent = speaker.name;
987
  selectedSpeakerDescDisplay.textContent = speaker.desc || "گوینده منتخب شما";
@@ -990,7 +1082,7 @@
990
 
991
  function createSpeakerCardsInModal() {
992
  speakerGridInModal.innerHTML = '';
993
- speakers.forEach((speaker, index) => {
994
  const cardLabel = document.createElement('label');
995
  cardLabel.className = 'speaker-card';
996
  cardLabel.setAttribute('for', `modal-speaker-${speaker.id}`);
@@ -998,15 +1090,12 @@
998
  cardLabel.innerHTML = `
999
  <input type="radio" name="modal_speaker_selection" value="${speaker.id}" id="modal-speaker-${speaker.id}" ${isChecked}>
1000
  <div class="speaker-visual">
1001
- <img src="${getImageUrl(speaker, index, 'thumb')}" alt="${speaker.name}" loading="lazy">
1002
  <div class="speaker-name">${speaker.name}</div>
1003
  </div>
1004
  `;
1005
- cardLabel.addEventListener('click', (e) => {
1006
- if (e.target.name !== "modal_speaker_selection") {
1007
- const radio = cardLabel.querySelector('input[type="radio"]');
1008
- if(radio) radio.checked = true;
1009
- }
1010
  updateSelectedSpeakerDisplay(speaker.id);
1011
  hideModal(speakerModal);
1012
  });
@@ -1014,9 +1103,10 @@
1014
  });
1015
  }
1016
 
1017
- // --- Modal Management ---
1018
  function showModal(modalElement) {
1019
  modalElement.classList.add('visible');
 
1020
  setTimeout(() => {
1021
  const firstFocusable = modalElement.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
1022
  if (firstFocusable) firstFocusable.focus();
@@ -1030,149 +1120,243 @@
1030
  }, { once: true });
1031
  }
1032
 
1033
- // Speaker Modal Listeners
1034
- changeSpeakerBtn.addEventListener('click', () => {
1035
- createSpeakerCardsInModal();
1036
- showModal(speakerModal);
1037
- });
1038
- selectedSpeakerCard.addEventListener('click', () => {
1039
- createSpeakerCardsInModal();
1040
- showModal(speakerModal);
1041
- });
1042
-
1043
- // Info Modal Listener
1044
- tempInfoIcon.addEventListener('click', () => showModal(infoModal));
1045
- tempInfoIcon.addEventListener('keydown', (e) => {
1046
- if (e.key === 'Enter' || e.key === ' ') {
1047
- e.preventDefault();
1048
- showModal(infoModal);
1049
- }
1050
- });
1051
 
1052
- // General Modal Close Listeners (for all modals)
1053
- document.querySelectorAll('.modal-overlay').forEach(overlay => {
1054
- overlay.addEventListener('click', (e) => {
1055
- if (e.target === overlay) {
1056
- hideModal(overlay);
1057
- }
1058
- });
1059
- });
1060
- document.querySelectorAll('.close-modal-btn').forEach(button => {
1061
- button.addEventListener('click', () => {
1062
- const modalId = button.dataset.modalId;
1063
- if (modalId) {
1064
- hideModal(document.getElementById(modalId));
1065
- }
1066
- });
1067
- });
1068
- document.addEventListener('keydown', (e) => {
1069
- if (e.key === 'Escape') {
1070
- document.querySelectorAll('.modal-overlay.visible').forEach(hideModal);
1071
- }
1072
- });
1073
 
1074
- tempSlider.addEventListener('input', () => { tempValueSpan.textContent = tempSlider.value; });
 
 
1075
 
1076
- // --- Custom Audio Player Functions ---
 
1077
 
1078
- function formatTime(seconds) {
1079
- if (isNaN(seconds) || seconds < 0) return '0:00';
1080
- const minutes = Math.floor(seconds / 60);
1081
- const remainingSeconds = Math.floor(seconds % 60);
1082
- return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
1083
- }
1084
 
1085
- // Function to draw waveform on canvas
1086
- function drawWaveform(progressRatio = 0) {
1087
- waveformCtx.clearRect(0, 0, audioWaveformCanvas.width, audioWaveformCanvas.height); // Clear canvas
1088
 
1089
- const barWidth = 3; // From CSS
1090
- const barGap = 2; // Space between bars (adjust for visual appeal)
1091
  const totalBarAndGap = barWidth + barGap;
1092
- const numBars = Math.floor(audioWaveformCanvas.width / totalBarAndGap);
1093
- const offset = (audioWaveformCanvas.width - (numBars * totalBarAndGap)) / 2; // Center bars
1094
 
1095
  const activeBars = Math.floor(progressRatio * numBars);
1096
 
1097
  for (let i = 0; i < numBars; i++) {
1098
- const peak = audioPeaks[i] || 0; // Use actual peak or 0 if data isn't enough
1099
- const barHeight = peak * audioWaveformCanvas.height;
1100
-
1101
- waveformCtx.fillStyle = i < activeBars ?
1102
- getComputedStyle(document.documentElement).getPropertyValue('--waveform-color-active').trim() :
 
1103
  getComputedStyle(document.documentElement).getPropertyValue('--waveform-color-inactive').trim();
1104
 
1105
  const x = offset + i * totalBarAndGap;
1106
- const y = (audioWaveformCanvas.height - barHeight) / 2; // Center vertically
1107
 
1108
  waveformCtx.fillRect(x, y, barWidth, barHeight);
1109
  }
1110
  }
1111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1112
  // Update custom player UI (play/pause icon, time, progress)
1113
  function updatePlayerUI() {
1114
  if (hiddenAudioPlayer.paused || hiddenAudioPlayer.ended) {
1115
  playIcon.style.display = 'block';
1116
  pauseIcon.style.display = 'none';
 
1117
  } else {
1118
  playIcon.style.display = 'none';
1119
  pauseIcon.style.display = 'block';
 
1120
  }
1121
 
1122
  const currentTime = hiddenAudioPlayer.currentTime;
1123
  const duration = hiddenAudioPlayer.duration;
1124
 
1125
  audioCurrentTimeSpan.textContent = formatTime(currentTime);
 
 
1126
 
1127
  if (isFinite(duration) && duration > 0) {
1128
  audioTotalTimeSpan.textContent = formatTime(duration);
1129
  const progressRatio = currentTime / duration;
1130
- drawWaveform(progressRatio); // Redraw waveform based on progress
1131
  } else {
1132
  audioTotalTimeSpan.textContent = '0:00';
1133
- drawWaveform(0); // Draw empty waveform
1134
  }
1135
  }
1136
 
1137
- // Process audio data for waveform visualization
1138
- async function processAudioForWaveform(audioBuffer) {
1139
- if (!audioBuffer) {
1140
- audioPeaks = [];
1141
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1142
  }
1143
- const channelData = audioBuffer.getChannelData(0); // Get data from first channel
 
 
1144
 
1145
- // Determine number of bars based on current canvas width
1146
- const barWidth = 3;
1147
- const barGap = 2;
1148
- const totalBarAndGap = barWidth + barGap;
1149
- const numBars = Math.floor(audioWaveformCanvas.offsetWidth / totalBarAndGap);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
 
1151
- const samplesPerBar = Math.floor(channelData.length / numBars);
1152
- const peaks = [];
1153
 
1154
- for (let i = 0; i < numBars; i++) {
1155
- let sum = 0;
1156
- let maxPeak = 0;
1157
- const start = i * samplesPerBar;
1158
- const end = Math.min(start + samplesPerBar, channelData.length);
1159
 
1160
- for (let j = start; j < end; j++) {
1161
- const value = Math.abs(channelData[j]);
1162
- sum += value;
1163
- if (value > maxPeak) {
1164
- maxPeak = value;
1165
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1166
  }
1167
- // Use max peak for sharper visualization, or average for smoother
1168
- // Clamp between 0 and 1
1169
- peaks.push(Math.min(1, Math.max(0, maxPeak)));
 
 
 
 
 
 
 
 
 
 
1170
  }
1171
- audioPeaks = peaks;
1172
- drawWaveform(0); // Draw initial waveform
1173
- }
1174
 
1175
- // Event Listeners for Custom Player Controls
1176
  playPauseBtn.addEventListener('click', () => {
1177
  if (hiddenAudioPlayer.paused) {
1178
  hiddenAudioPlayer.play();
@@ -1190,6 +1374,7 @@
1190
  hiddenAudioPlayer.muted = !hiddenAudioPlayer.muted;
1191
  volumeHighIcon.style.display = hiddenAudioPlayer.muted ? 'none' : 'block';
1192
  volumeMuteIcon.style.display = hiddenAudioPlayer.muted ? 'block' : 'none';
 
1193
  });
1194
  speedBtn.addEventListener('click', () => {
1195
  currentPlaybackSpeedIndex = (currentPlaybackSpeedIndex + 1) % playbackSpeeds.length;
@@ -1198,28 +1383,34 @@
1198
  speedBtn.textContent = `${newSpeed}x`;
1199
  });
1200
 
 
1201
  hiddenAudioPlayer.addEventListener('timeupdate', updatePlayerUI);
1202
  hiddenAudioPlayer.addEventListener('loadedmetadata', async () => {
1203
- // Ensure AudioContext is available
1204
  if (!audioContext) {
1205
  audioContext = new (window.AudioContext || window.webkitAudioContext)();
1206
  }
1207
- // Fetch and decode audio to get waveform data
1208
- try {
1209
- const response = await fetch(hiddenAudioPlayer.src);
1210
- const arrayBuffer = await response.arrayBuffer();
1211
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
1212
-
1213
- // Set canvas dimensions
1214
- audioWaveformCanvas.width = audioWaveformCanvas.offsetWidth;
1215
- audioWaveformCanvas.height = audioWaveformCanvas.offsetHeight;
1216
-
1217
- await processAudioForWaveform(audioBuffer);
1218
- } catch (e) {
1219
- console.error("Error decoding audio for waveform:", e);
1220
- audioPeaks = []; // Clear peaks on error
 
 
1221
  }
1222
  updatePlayerUI(); // Update UI with total duration and initial state
 
 
 
1223
  });
1224
  hiddenAudioPlayer.addEventListener('play', updatePlayerUI);
1225
  hiddenAudioPlayer.addEventListener('pause', updatePlayerUI);
@@ -1233,156 +1424,60 @@
1233
  window.addEventListener('resize', () => {
1234
  clearTimeout(resizeTimeout);
1235
  resizeTimeout = setTimeout(() => {
1236
- if (outputSection.classList.contains('has-content') && hiddenAudioPlayer.src) {
1237
- // Re-process audio buffer on resize to adjust number of bars
1238
- // This assumes the audio buffer is still in memory or re-fetches it.
1239
- // A more efficient way for real apps might be to store the AudioBuffer.
1240
- // For simplicity here, we trigger the loadedmetadata logic again.
1241
- hiddenAudioPlayer.dispatchEvent(new Event('loadedmetadata'));
1242
  }
1243
  }, 250); // Debounce resize events
1244
  });
1245
 
1246
 
1247
- // --- State Management Functions ---
1248
- function showLoadingState() {
1249
- audioPlayerContent.style.display = 'none';
1250
- outputSection.classList.remove('has-content');
1251
- statusMessage.style.display = 'none'; // Hide initial message
1252
- loadingAnimationWrapper.style.display = 'flex';
1253
- generateBtn.disabled = true;
1254
- generateBtn.innerHTML = `
1255
- <svg aria-hidden="true" role="status" fill="currentColor" viewBox="0 0 100 101" style="display:inline-block; margin-left: 0.5em; width:1.2em; height:1.2em; vertical-align: middle; animation: spin 1s linear infinite;">
1256
- <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="#E5E7EB"/>
1257
- <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0492C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentColor"/>
1258
- </svg>
1259
- در حال پردازش...
1260
- `;
1261
- }
1262
-
1263
- function showResultState(isSuccess, message = '') {
1264
- loadingAnimationWrapper.style.display = 'none';
1265
- if (isSuccess) {
1266
- statusMessage.style.display = 'none';
1267
- audioPlayerContent.style.display = 'flex';
1268
- outputSection.classList.add('has-content');
1269
- // Play is triggered by loadedmetadata listener after waveform processing
1270
- } else {
1271
- statusMessage.textContent = message || 'خطایی رخ داد. لطفاً دوباره تلاش کنید.';
1272
- statusMessage.style.display = 'block';
1273
- audioPlayerContent.style.display = 'none';
1274
- outputSection.classList.remove('has-content');
1275
- hiddenAudioPlayer.pause();
1276
- hiddenAudioPlayer.src = '';
1277
- audioPeaks = []; // Clear waveform data
1278
- drawWaveform(0); // Clear canvas
1279
- }
1280
- generateBtn.disabled = false;
1281
- generateBtn.innerHTML = '✨ تولید صدا با آلفا';
1282
- }
1283
-
1284
  async function generateAudio(event) {
1285
  event.preventDefault();
1286
- showLoadingState();
1287
 
1288
- const text = textInput.value;
1289
- if (!text.trim()) {
1290
- showResultState(false, 'خطا: متن ورودی نمی‌تواند خالی باشد.');
1291
  return;
1292
  }
1293
  if (text.length > MAX_CHARS) {
1294
  showResultState(false, `خطا: طول متن بیش از ${MAX_CHARS.toLocaleString('fa-IR')} نویسه است.`);
1295
  return;
1296
  }
 
 
 
 
 
1297
 
1298
- const promptVal = promptInput.value;
 
 
 
1299
  const temperatureVal = parseFloat(tempSlider.value);
1300
  const selectedSpeakerVal = selectedSpeakerIdStorage.value;
1301
- const sessionHash = Math.random().toString(36).substring(2);
1302
-
1303
- const payload = {
1304
- fn_index: FN_INDEX,
1305
- data: [false, null, text, promptVal, selectedSpeakerVal, temperatureVal],
1306
- event_data: null,
1307
- session_hash: sessionHash
1308
- };
1309
 
1310
  try {
1311
- const joinQueueResponse = await fetch(JOIN_QUEUE_URL, {
1312
- method: "POST",
1313
- headers: { "Content-Type": "application/json" },
1314
- body: JSON.stringify(payload)
1315
- });
1316
 
1317
- if (!joinQueueResponse.ok) {
1318
- const errorBody = await joinQueueResponse.text();
1319
- console.error("Join Queue Error Body:", errorBody);
1320
- throw new Error(`خطا در برقراری ارتباط با سرویس آلفا (${joinQueueResponse.status}). لطفا لحظاتی دیگر تلاش کنید.`);
1321
- }
1322
-
1323
- let finalFilePath = null;
1324
- const startTime = Date.now();
1325
- const timeoutDuration = 90000;
1326
-
1327
- while (Date.now() - startTime < timeoutDuration) {
1328
- const dataResponse = await fetch(`${GET_DATA_URL_BASE}?session_hash=${sessionHash}`);
1329
- if (!dataResponse.ok) {
1330
- const errorBody = await dataResponse.text();
1331
- console.error("Get Data Error Body:", errorBody);
1332
- throw new Error(`خطا در دریافت داده از سرویس (${dataResponse.status})`);
1333
- }
1334
-
1335
- const responseText = await dataResponse.text();
1336
- const lines = responseText.trim().split('\n');
1337
-
1338
- for (const line of lines) {
1339
- if (!line.startsWith('data:')) continue;
1340
- try {
1341
- const data = JSON.parse(line.substring(5));
1342
- if (data.msg === 'process_generating' || data.msg === 'process_starts') {
1343
- // console.log("Processing:", data.output?.progress_data?.[0]?.desc || data.msg);
1344
- } else if (data.msg === 'process_completed') {
1345
- if (data.success && data.output.data && data.output.data[0] && (data.output.data[0].name || data.output.data[0].path)) {
1346
- finalFilePath = data.output.data[0].name || data.output.data[0].path;
1347
- } else {
1348
- console.error("Invalid server response structure or unsuccessful processing:", data);
1349
- throw new Error(data.output?.error || 'خطای ناشناخته در پردازش سرور.');
1350
- }
1351
- break;
1352
- } else if (data.msg === 'queue_full') {
1353
- throw new Error('صف پردازش پر است. لطفا کمی بعد تلاش کنید.');
1354
- }
1355
- } catch (e) {
1356
- console.warn("Error parsing JSON from stream:", e, "Line:", line);
1357
- }
1358
- }
1359
- if (finalFilePath) break;
1360
- await new Promise(resolve => setTimeout(resolve, 1000));
1361
- }
1362
-
1363
- if (finalFilePath) {
1364
- const audioUrl = `${FILE_URL_BASE}${finalFilePath}`;
1365
- hiddenAudioPlayer.src = audioUrl;
1366
- showResultState(true);
1367
- } else if (Date.now() - startTime >= timeoutDuration) {
1368
- throw new Error('پردازش بیش از حد طول کشید و متوقف شد.');
1369
- } else {
1370
- throw new Error('فایل صوتی از سرور دریافت نشد یا پردازش ناموفق بود.');
1371
- }
1372
 
 
1373
  } catch (error) {
1374
  console.error('خطا در فرآیند تولید صدا:', error);
1375
- showResultState(false, `${error.message}`);
1376
  }
1377
  }
1378
 
1379
  // Initial setup
1380
  updateSelectedSpeakerDisplay(selectedSpeakerIdStorage.value || speakers[0].id);
1381
  form.addEventListener('submit', generateAudio);
1382
- statusMessage.style.display = 'block';
1383
- loadingAnimationWrapper.style.display = 'none';
1384
- audioPlayerContent.style.display = 'none';
1385
- outputSection.classList.remove('has-content');
1386
  });
1387
  </script>
1388
  </body>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Alpha TTS Pro - نسل جدید تبدیل متن به صدا</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%233B82F6' d='M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z'/%3E%3C/svg%3E" type="image/svg+xml">
8
  <style>
9
  @import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800;900&display=swap');
10
 
 
21
  --accent-secondary-hover: #059669;
22
  --input-bg: #F8FAFC;
23
  --input-border-focus: var(--accent-primary);
24
+ --error-color: #EF4444;
25
 
26
  --radius-card: 24px;
27
  --radius-input: 14px;
 
32
  --transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
33
 
34
  /* Custom Audio Player Colors */
35
+ --waveform-color-active: var(--accent-primary);
36
+ --waveform-color-inactive: #D0D9E6;
37
+ --waveform-dashed-line-color: #E0E4E9;
38
  }
39
 
40
  @keyframes fadeInDown {
 
53
  from { opacity: 1; transform: scale(1) translateY(0); }
54
  to { opacity: 0; transform: scale(0.8) translateY(20px); }
55
  }
 
 
56
  @keyframes spin {
57
  from { transform: rotate(0deg); }
58
  to { transform: rotate(360deg); }
59
  }
60
+ @keyframes pulse {
61
+ 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); }
62
+ 70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
63
+ 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
64
+ }
65
+ @keyframes typing {
66
+ from { width: 0 }
67
+ to { width: 100% }
68
+ }
69
+ @keyframes blink-caret {
70
+ from, to { border-color: transparent }
71
+ 50% { border-color: var(--accent-primary); }
72
+ }
73
 
74
  body {
75
  font-family: var(--app-font);
 
100
  text-align: center;
101
  margin-bottom: 1.5rem;
102
  animation: fadeInDown 0.8s 0.1s ease-out backwards;
103
+ position: relative; /* For credits display */
104
  }
105
  .app-header h1 {
106
  font-size: 2.1em;
 
119
  font-weight: 400;
120
  line-height: 1.6;
121
  }
122
+ .credits-display {
123
+ position: absolute;
124
+ top: 0;
125
+ right: 0;
126
+ background-color: var(--panel-bg);
127
+ border: 1px solid var(--panel-border);
128
+ padding: 0.6rem 1.2rem;
129
+ border-radius: var(--radius-input);
130
+ font-size: 0.85em;
131
+ font-weight: 600;
132
+ color: var(--text-primary);
133
+ box-shadow: var(--shadow-subtle);
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 0.4rem;
137
+ }
138
+ .credits-display svg {
139
+ width: 1.2em; height: 1.2em;
140
+ fill: var(--accent-secondary);
141
+ }
142
+ @media (max-width: 600px) {
143
+ .credits-display {
144
+ position: relative;
145
+ top: auto;
146
+ right: auto;
147
+ margin: 1rem auto 0;
148
+ width: fit-content;
149
+ }
150
+ }
151
+
152
 
153
  .main-content {
154
  padding: 2.5rem;
 
233
  padding: 0 0.2rem;
234
  }
235
  #char-count { font-weight: 600; color: var(--accent-primary); }
236
+ #char-count.error { color: var(--error-color); }
237
 
238
 
239
  /* --- نمایش گوینده منتخب --- */
 
250
  position: relative;
251
  margin-bottom: 1.2rem;
252
  cursor: pointer;
253
+ text-align: right;
254
+ max-width: 90%;
255
  }
256
  #selected-speaker-card:hover {
257
  transform: translateY(-6px) scale(1.03);
 
319
  padding: 2rem;
320
  border-radius: var(--radius-card);
321
  width: 90%;
322
+ max-height: 90vh; /* Ensure modal fits viewport */
323
+ overflow-y: auto; /* Enable scrolling for content overflow */
324
  box-shadow: var(--shadow-strong);
325
  border: 1px solid var(--panel-border);
326
  opacity: 0; /* For animation */
 
344
  /* --- مودال گالری گویندگان (اختصاصی) --- */
345
  #speaker-modal .modal-dialog {
346
  max-width: 700px;
 
347
  }
348
  #speaker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 1.2rem; }
349
  @media (min-width: 640px) { #speaker-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } }
 
500
  transform: none;
501
  }
502
  #generate-btn:disabled::before { display: none; }
503
+ #generate-btn .button-text {
504
+ display: inline-block;
505
+ vertical-align: middle;
506
+ }
507
+ #generate-btn svg.spinner {
508
  display:inline-block; margin-left: 0.5em; width:1.2em; height:1.2em;
509
+ vertical-align: middle; animation: spin 1s linear infinite;
510
  }
511
 
512
+
513
  /* --- Output Section (now the card container for status/loading/player) --- */
514
  #output-section {
515
  margin-top: 3rem;
 
539
  display: none !important;
540
  }
541
 
542
+ #status-message {
543
+ font-weight: 500; color: var(--text-secondary); text-align: center; font-size: 1.1em;
544
+ animation: fadeIn 0.5s ease-out;
545
+ }
546
+ #status-message.error {
547
+ color: var(--error-color);
548
+ font-weight: 600;
549
+ }
550
 
551
 
552
  /* --- انیمیشن پردازش --- */
 
618
  box-sizing: border-box;
619
  flex-direction: column;
620
  gap: 1.2rem;
621
+ animation: fadeIn 0.5s ease-out;
622
  }
623
 
624
  .audio-waveform-container {
625
  display: flex;
626
  align-items: center;
627
+ gap: 1rem;
628
  width: 100%;
629
  margin-bottom: 1rem;
630
  }
631
  .audio-time {
632
  font-size: 0.9em;
633
  color: var(--text-secondary);
634
+ min-width: 40px;
635
  text-align: center;
636
+ font-variant-numeric: tabular-nums;
637
+ user-select: none;
638
  }
639
 
 
640
  .audio-waveform {
641
  flex-grow: 1;
642
+ height: 60px;
643
  position: relative;
644
+ overflow: hidden;
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: center;
648
+ margin-bottom: 0.5rem;
649
  }
650
 
651
  #audio-waveform-canvas {
652
+ display: block;
653
+ max-width: 100%;
654
+ height: 100%;
655
  }
656
 
657
  .audio-waveform-dashed-line {
 
664
  background-position: center;
665
  background-size: 10px 1px;
666
  transform: translateY(-50%);
667
+ z-index: 1;
668
  }
669
 
670
  /* Audio Controls */
 
672
  display: flex;
673
  justify-content: center;
674
  align-items: center;
675
+ gap: 1.5rem;
676
+ margin-bottom: 1rem;
677
  }
678
  .audio-skip-btn, .audio-play-pause-btn-large, .audio-volume-btn, .audio-speed-btn {
679
  background: none;
680
  border: none;
681
  cursor: pointer;
682
+ padding: 8px;
683
  transition: transform 0.2s, opacity 0.2s;
684
  }
685
  .audio-skip-btn, .audio-play-pause-btn-large, .audio-volume-btn {
686
+ color: var(--text-secondary);
687
  }
688
  .audio-skip-btn:hover, .audio-play-pause-btn-large:hover, .audio-volume-btn:hover {
689
  opacity: 0.8;
 
697
  fill: currentColor;
698
  }
699
  .audio-play-pause-btn-large {
700
+ padding: 0;
701
  width: 50px;
702
  height: 50px;
703
+ border-radius: 50%;
704
+ background-color: var(--accent-primary);
705
+ color: white;
706
+ box-shadow: 0 4px 10px -2px rgba(59, 130, 246, 0.35);
707
+ }
708
+ .audio-play-pause-btn-large:hover {
709
+ background-color: var(--accent-primary-hover);
710
+ transform: translateY(-2px) scale(1.05);
711
  }
712
  .audio-play-pause-btn-large svg {
713
  width: 38px;
 
718
  .audio-utility-controls {
719
  display: flex;
720
  align-items: center;
721
+ justify-content: space-between;
722
  width: 100%;
723
  }
724
  .audio-volume-btn svg {
 
737
  text-align: center;
738
  color: var(--text-primary);
739
  box-shadow: var(--shadow-subtle);
740
+ padding: 5px 12px;
741
  }
742
  .audio-speed-btn:hover {
743
  background-color: var(--input-bg);
744
+ transform: translateY(-2px);
745
+ box-shadow: var(--shadow-medium);
746
  }
747
+
 
748
  #hidden-audio-player { display: none !important; }
749
 
750
  /* Responsive adjustments for mobile */
 
760
  }
761
  #audio-player-content {
762
  padding: 1rem;
763
+ gap: 0.8rem;
764
  }
765
  .audio-waveform-container {
766
+ gap: 0.5rem;
767
  }
768
  .audio-time {
769
+ font-size: 0.85em;
770
  min-width: 35px;
771
  }
772
  .audio-skip-btn svg {
 
782
  height: 32px;
783
  }
784
  .audio-controls-group {
785
+ gap: 1rem;
786
  }
787
  .audio-volume-btn svg {
788
  width: 20px;
 
792
  font-size: 0.8em;
793
  padding: 5px 10px;
794
  }
795
+ #selected-speaker-card {
796
+ flex-direction: column;
797
+ padding: 1.2rem 1rem;
798
+ max-width: none;
799
+ margin-bottom: 1rem;
800
+ }
801
+ #selected-speaker-card img {
802
+ margin: 0 0 10px 0;
803
+ }
804
+ #selected-speaker-info {
805
+ text-align: center;
806
+ }
807
  }
808
  </style>
809
  </head>
810
  <body>
811
  <div class="container">
812
  <header class="app-header">
813
+ <div class="credits-display" aria-label="اعتبار باقیمانده شما">
814
+ <svg viewBox="0 0 24 24" fill="currentColor">
815
+ <path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm.293 15.293a1 1 0 001.414 0l4-4a1 1 0 00-1.414-1.414L13 14.586V7a1 1 0 00-2 0v7.586l-2.293-2.293a1 1 0 00-1.414 1.414l4 4z"></path>
816
+ </svg>
817
+ <span id="credits-left">10,000</span> کاراکتر باقی‌مانده
818
+ </div>
819
+ <h1>تبدیل متن به صدا با هوش مصنوعی آلفا پرو</h1>
820
+ <p>صدایی نو، تجربه‌ای نوین در تبدیل متن به گفتار با هوش مصنوعی پیشرفته برای کاربران ویژه.</p>
821
  </header>
822
 
823
  <main class="main-content">
824
  <form id="tts-form">
825
  <div class="form-group">
826
  <label for="text-input">📝 متن مورد نظر شما</label>
827
+ <textarea id="text-input" rows="4" placeholder="متن خود را اینجا وارد کنید..." aria-required="true"></textarea>
828
  <div class="char-counter-wrapper">
829
+ <span id="char-count">0</span> / <span id="char-max">5000</span> نویسه
830
+ <span id="char-error-message" style="display:none; color:var(--error-color); margin-right: 10px;"></span>
831
  </div>
832
  </div>
833
  <div class="form-group">
834
  <label for="prompt-input">🗣️ توصیف سبک و لحن گفتار (اختیاری)</label>
835
+ <input type="text" id="prompt-input" placeholder="مثال: با لحنی رسمی و موقر، یا شاد و کودکانه" aria-describedby="prompt-info">
836
+ <small id="prompt-info" style="display: block; margin-top: 5px; color: var(--text-secondary); font-size: 0.85em;">این ویژگی به هوش مصنوعی کمک می‌کند تا لحن دقیق‌تری ایجاد کند.</small>
837
  </div>
838
 
839
  <div class="form-group">
840
  <label>🎤 انتخاب گوینده حرفه‌ای</label>
841
  <div id="selected-speaker-display">
842
+ <div id="selected-speaker-card" role="button" tabindex="0" aria-label="برای تغییر گوینده کلیک کنید">
843
+ <img id="selected-speaker-img" src="" alt="عکس گوینده" loading="lazy">
844
  <div id="selected-speaker-info">
845
  <h3 id="selected-speaker-name"></h3>
846
  <p id="selected-speaker-desc">گوینده پیش‌فرض</p>
 
858
  <div class="form-group">
859
  <div class="label-with-info">
860
  <label for="temperature-slider">🌡️ میزان خلاقیت و نوآوری صدا</label>
861
+ <div class="info-icon" id="temp-info-icon" role="button" tabindex="0" aria-label="اطلاعات بیشتر درباره خلاقیت صدا">!
862
  </div>
863
  </div>
864
  <div class="slider-container">
865
+ <input type="range" id="temperature-slider" min="0.1" max="1.5" step="0.05" value="0.9" aria-valuemin="0.1" aria-valuemax="1.5" aria-valuenow="0.9" aria-label="تنظیم میزان خلاقیت صدا">
866
  <span id="temperature-value">0.9</span>
867
  </div>
868
  </div>
869
 
870
+ <button type="submit" id="generate-btn">
871
+ <span class="button-text">✨ تولید صدا با آلفا</span>
872
+ </button>
873
  </form>
874
 
875
+ <div id="output-section" aria-live="polite">
876
  <div id="status-message">صدای تولید شده در اینجا نمایش داده خواهد شد.</div>
877
  <div id="loading-animation-wrapper">
878
  <div class="orbital-loader">
 
887
  <div id="audio-player-content">
888
  <div class="audio-waveform-container">
889
  <span class="audio-time audio-current-time">0:00</span>
890
+ <div class="audio-waveform" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="پیشرفت پخش صدا">
891
  <canvas id="audio-waveform-canvas"></canvas>
892
  <div class="audio-waveform-dashed-line"></div>
893
  </div>
 
895
  </div>
896
 
897
  <div class="audio-controls-group">
898
+ <button class="audio-skip-btn backward" aria-label="5 ثانیه به عقب">
899
+ <svg viewBox="0 0 24 24" width="24" height="24"><path d="M11 16V8l-4 4 4 4zm4-12v16l7-8-7-8z"></path></svg>
900
  </button>
901
+ <button class="audio-play-pause-btn-large" aria-label="پخش/مکث">
902
  <svg viewBox="0 0 24 24" class="play-icon"><path d="M8 5v14l11-7z"></path></svg>
903
  <svg viewBox="0 0 24 24" class="pause-icon" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg>
904
  </button>
905
+ <button class="audio-skip-btn forward" aria-label="5 ثانیه به جلو">
906
+ <svg viewBox="0 0 24 24" width="24" height="24"><path d="M13 16V8l4 4-4 4zM9 4v16L2 12l7-8z"></path></svg>
907
  </button>
908
  </div>
909
 
910
  <div class="audio-utility-controls">
911
+ <button class="audio-volume-btn" aria-label="بی‌صدا کردن/فعال کردن صدا">
912
  <svg viewBox="0 0 24 24" class="volume-high-icon"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path></svg>
913
  <svg viewBox="0 0 24 24" class="volume-mute-icon" style="display:none;"><path d="M7 9v6h4l5 5V4L11 9H7zM16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zM19 12c0 .94-.23 1.82-.68 2.6L19 14.88c.45-.88.7-1.88.7-2.88 0-4.01-2.99-7.14-7-8.05v2.06c2.89.86 5 3.54 5 6.71zM4.55 4L2 6.55 9.45 14H7v6h4l5 5V14.55l4.05 4.05L22 18 12 8 4.55 4z"></path></svg>
914
  </button>
915
+ <button class="audio-speed-btn" aria-label="تغییر سرعت پخش">1x</button>
916
  </div>
917
  </div>
918
+ <audio id="hidden-audio-player" preload="auto"></audio>
919
  </div>
920
  </main>
921
  </div>
 
924
  <div id="speaker-modal" class="modal-overlay">
925
  <div class="modal-dialog">
926
  <div class="modal-header">
927
+ <h2>گالری گویندگان آلفا پرو</h2>
928
+ <button type="button" class="close-modal-btn" data-modal-id="speaker-modal" aria-label="بستن گالری گویندگان">×</button>
929
  </div>
930
  <div id="speaker-grid">
931
  <!-- Speaker cards will be generated here by JS -->
 
942
  </div>
943
  <div id="info-modal-content">
944
  <p>این تنظیم مشخص می‌کند که هوش مصنوعی تا چه حد در تولید صدا <strong>خلاقیت</strong> به خرج دهد.</p>
945
+ <p><strong>مقادیر بالاتر (مثلاً ۱.۰ تا ۱.۵):</strong> منجر به صدایی متنوع‌تر، پویاتر و گاهی اوقات غیرمنتظره‌تر می‌شود. مناسب برای زمانی که به دنبال لحنی خاص و منحصربه‌فرد هستید.</p>
946
+ <p><strong>مقادیر پایین‌تر (مثلاً ۰.۱ تا ۰.۵):</strong> صدایی پایدارتر، قابل پیش‌بینی‌تر و نزدیک‌تر به صدای استاندارد گوینده تولید می‌کند. مناسب برای خوانش متون رسمی یا زمانی که ثبات لحن اهمیت دارد.</p>
947
  <span class="range-info">محدوده پیشنهادی: ۰.۱ (پایدار) تا ۱.۵ (بسیار خلاق)</span>
948
  </div>
949
  </div>
 
954
 
955
  <script>
956
  document.addEventListener('DOMContentLoaded', () => {
957
+ // --- CONSTANTS & DOM ELEMENTS ---
958
+ const MAX_CHARS = 5000; // Reduced max chars for a "paid" tier, adjust as needed
 
 
 
 
959
  const speakers = [
960
+ { id: "Charon", name: "شهاب (مرد)", desc: "صدایی قدرتمند و رسا", gender: "men" },
961
+ { id: "Zephyr", name: "آوا (زن)", desc: "لطیف و دلنشین", gender: "women" },
962
+ { id: "Achird", name: "نوید (مرد)", desc: "جوان و پرانرژی", gender: "men" },
963
+ { id: "Zubenelgenubi", name: "رویا (زن)", desc: "گرم و صمیمی", gender: "women" },
964
+ { id: "Vindemiatrix", name: "کیان (مرد)", desc: "باوقار و رسمی", gender: "men" },
965
+ { id: "Sadachbia", name: "پریسا (زن)", desc: "شاداب و پویا", gender: "women" },
966
+ { id: "Sadaltager", name: "آرش (مرد)", desc: "مطمئن و تاثیرگذار", gender: "men" },
967
+ { id: "Sulafat", name: "شبنم (زن)", desc: "آرام و متین", gender: "women" },
968
+ { id: "Laomedeia", name: "سهیل (مرد)", desc: "دوستانه و گیرا", gender: "men" },
969
+ { id: "Achernar", name: "مریم (زن)", desc: "حرفه‌ای و واضح", gender: "women" },
970
+ { id: "Alnilam", name: "بهرام (مرد)", desc: "حماسی و نافذ", gender: "men" },
971
+ { id: "Schedar", name: "نگار (زن)", desc: "مهربان و شیرین", gender: "women" },
972
+ { id: "Gacrux", name: "فرید (مرد)", desc: "پخته و قابل اعتماد", gender: "men" },
973
+ { id: "Pulcherrima", name: "سارا (زن)", desc: "جذاب و مدرن", gender: "women" },
974
+ { id: "Umbriel", name: "مانی (مرد)", desc: "خلاق و متفاوت", gender: "men" },
975
+ { id: "Algieba", name: "آناهیتا (زن)", desc: "با اصالت و شیک", gender: "women" },
976
+ { id: "Despina", name: "دلنواز (زن)", desc: "هنری و احساسی", gender: "women" },
977
+ { id: "Erinome", name: "رسا (مرد)", desc: "شفاف و گویا", gender: "men" },
978
+ { id: "Algenib", name: "امید (مرد)", desc: "انگیزه بخش و مثبت", gender: "men" },
979
+ { id: "Rasalthgeti", name: "الهه (زن)", desc: "اسرارآمیز و فریبنده", gender: "women" },
980
+ { id: "Orus", name: "بردیا (مرد)", desc: "ورزشی و پرهیجان", gender: "men" },
981
+ { id: "Aoede", name: "ترانه (زن)", desc: "موزیکال و خوش‌آهنگ", gender: "women" },
982
+ { id: "Callirrhoe", name: "نیما (مرد)", desc: "روایتگر و قصه‌گو", gender: "men" },
983
+ { id: "Autonoe", name: "هستی (زن)", "desc": "طبیعی و خودمانی", gender: "women" },
984
+ { id: "Enceladus", name: "کامیار (مرد)", desc: "مصمم و جدی", gender: "men" },
985
+ { id: "Iapetus", name: "ستاره (زن)", desc: "درخشان و گیرا", gender: "women" },
986
+ { id: "Puck", name: "پویا (مرد)", desc: "بازیگوش و سرزنده", gender: "men" },
987
+ { id: "Kore", name: "مهتاب (زن)", desc: "نجواگر و آرامش‌بخش", gender: "women" },
988
+ { id: "Fenrir", name: "سام (مرد)", desc: "جسور و بی‌باک", gender: "men" },
989
+ { id: "Leda", name: "لیدا (زن)", desc: "کلاسیک و باوقار", gender: "women" }
990
  ];
991
 
992
  const form = document.getElementById('tts-form');
 
995
  const tempSlider = document.getElementById('temperature-slider');
996
  const tempValueSpan = document.getElementById('temperature-value');
997
  const generateBtn = document.getElementById('generate-btn');
998
+ const generateButtonText = generateBtn.querySelector('.button-text');
999
 
1000
  const outputSection = document.getElementById('output-section');
1001
  const statusMessage = document.getElementById('status-message');
 
1031
  const volumeMuteIcon = volumeBtn.querySelector('.volume-mute-icon');
1032
  const speedBtn = audioPlayerContent.querySelector('.audio-speed-btn');
1033
 
 
1034
  const playbackSpeeds = [1.0, 1.25, 1.5, 0.75]; // Adjusted speeds as common values
1035
+ let currentPlaybackSpeedIndex = 0;
1036
  let audioPeaks = []; // Stores calculated peaks for waveform
1037
+ let audioContext = null; // Will be initialized when needed for waveform
1038
+ let audioBufferCache = null; // Cache for the decoded audio buffer
1039
 
1040
  // Character Counter
1041
  const charCountSpan = document.getElementById('char-count');
1042
  const charMaxSpan = document.getElementById('char-max');
1043
+ const charErrorMessage = document.getElementById('char-error-message');
1044
  charMaxSpan.textContent = MAX_CHARS.toLocaleString('fa-IR');
1045
 
1046
+ // Credits Display (Placeholder)
1047
+ const creditsLeftSpan = document.getElementById('credits-left');
1048
+ let currentCredits = 10000; // Initial mock credits
1049
+ creditsLeftSpan.textContent = currentCredits.toLocaleString('fa-IR');
1050
+
1051
+
1052
+ // --- HELPER FUNCTIONS ---
1053
+
1054
+ function formatTime(seconds) {
1055
+ if (isNaN(seconds) || seconds < 0) return '0:00';
1056
+ const minutes = Math.floor(seconds / 60);
1057
+ const remainingSeconds = Math.floor(seconds % 60);
1058
+ return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
1059
+ }
1060
 
1061
  function getSpeakerById(id) {
1062
  return speakers.find(s => s.id === id) || speakers[0];
1063
  }
1064
 
1065
+ // Uses randomuser.me for diverse, consistent placeholder images
1066
+ function getImageUrl(speaker, size = 'thumb') {
1067
+ const seed = speaker.id.charCodeAt(0) + speaker.id.length; // Simple seed
1068
+ const genderPath = speaker.gender === 'men' ? 'men' : 'women';
1069
+ const imageIndex = (seed % 100) + 1; // Ensure index is 1-100
1070
+ const sizePath = size === 'thumb' ? 'thumb/' : '';
1071
+ return `https://randomuser.me/api/portraits/${sizePath}${genderPath}/${imageIndex}.jpg`;
1072
  }
1073
 
1074
  function updateSelectedSpeakerDisplay(speakerId) {
1075
  const speaker = getSpeakerById(speakerId);
1076
+ selectedSpeakerImgDisplay.src = getImageUrl(speaker, 'large');
 
1077
  selectedSpeakerImgDisplay.alt = `عکس گوینده ${speaker.name}`;
1078
  selectedSpeakerNameDisplay.textContent = speaker.name;
1079
  selectedSpeakerDescDisplay.textContent = speaker.desc || "گوینده منتخب شما";
 
1082
 
1083
  function createSpeakerCardsInModal() {
1084
  speakerGridInModal.innerHTML = '';
1085
+ speakers.forEach((speaker) => {
1086
  const cardLabel = document.createElement('label');
1087
  cardLabel.className = 'speaker-card';
1088
  cardLabel.setAttribute('for', `modal-speaker-${speaker.id}`);
 
1090
  cardLabel.innerHTML = `
1091
  <input type="radio" name="modal_speaker_selection" value="${speaker.id}" id="modal-speaker-${speaker.id}" ${isChecked}>
1092
  <div class="speaker-visual">
1093
+ <img src="${getImageUrl(speaker, 'thumb')}" alt="${speaker.name}" loading="lazy">
1094
  <div class="speaker-name">${speaker.name}</div>
1095
  </div>
1096
  `;
1097
+ // Attach click listener to the label itself, which implicitly selects the radio
1098
+ cardLabel.addEventListener('click', () => {
 
 
 
1099
  updateSelectedSpeakerDisplay(speaker.id);
1100
  hideModal(speakerModal);
1101
  });
 
1103
  });
1104
  }
1105
 
1106
+ // --- MODAL MANAGEMENT ---
1107
  function showModal(modalElement) {
1108
  modalElement.classList.add('visible');
1109
+ // Set focus to the first focusable element for accessibility
1110
  setTimeout(() => {
1111
  const firstFocusable = modalElement.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
1112
  if (firstFocusable) firstFocusable.focus();
 
1120
  }, { once: true });
1121
  }
1122
 
1123
+ // --- AUDIO PLAYER VISUALIZATION (WAVEFORM) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
 
1125
+ // Function to draw waveform on canvas
1126
+ function drawWaveform(progressRatio = 0) {
1127
+ if (!audioWaveformCanvas || !waveformCtx) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
 
1129
+ const dpr = window.devicePixelRatio || 1; // Get device pixel ratio
1130
+ const canvasWidth = audioWaveformCanvas.offsetWidth * dpr;
1131
+ const canvasHeight = audioWaveformCanvas.offsetHeight * dpr;
1132
 
1133
+ audioWaveformCanvas.width = canvasWidth;
1134
+ audioWaveformCanvas.height = canvasHeight;
1135
 
1136
+ waveformCtx.clearRect(0, 0, canvasWidth, canvasHeight); // Clear canvas
 
 
 
 
 
1137
 
1138
+ if (audioPeaks.length === 0) return; // No peaks to draw
 
 
1139
 
1140
+ const barWidth = 3 * dpr;
1141
+ const barGap = 2 * dpr;
1142
  const totalBarAndGap = barWidth + barGap;
1143
+ const numBars = Math.floor(canvasWidth / totalBarAndGap);
1144
+ const offset = (canvasWidth - (numBars * totalBarAndGap)) / 2; // Center bars
1145
 
1146
  const activeBars = Math.floor(progressRatio * numBars);
1147
 
1148
  for (let i = 0; i < numBars; i++) {
1149
+ const peakIndex = Math.floor((i / numBars) * audioPeaks.length);
1150
+ const peak = audioPeaks[peakIndex] || 0;
1151
+ const barHeight = peak * canvasHeight * 0.9; // Scale height slightly to prevent touching edges
1152
+
1153
+ waveformCtx.fillStyle = i < activeBars ?
1154
+ getComputedStyle(document.documentElement).getPropertyValue('--waveform-color-active').trim() :
1155
  getComputedStyle(document.documentElement).getPropertyValue('--waveform-color-inactive').trim();
1156
 
1157
  const x = offset + i * totalBarAndGap;
1158
+ const y = (canvasHeight - barHeight) / 2; // Center vertically
1159
 
1160
  waveformCtx.fillRect(x, y, barWidth, barHeight);
1161
  }
1162
  }
1163
 
1164
+ // Process audio data for waveform visualization
1165
+ async function processAudioForWaveform(audioBuffer) {
1166
+ if (!audioBuffer) {
1167
+ audioPeaks = [];
1168
+ return;
1169
+ }
1170
+
1171
+ // Get mono audio data (or average stereo if available)
1172
+ const channelData = audioBuffer.numberOfChannels > 0 ? audioBuffer.getChannelData(0) : [];
1173
+ if (audioBuffer.numberOfChannels > 1) {
1174
+ const channelData2 = audioBuffer.getChannelData(1);
1175
+ for (let i = 0; i < channelData.length; i++) {
1176
+ channelData[i] = (channelData[i] + channelData2[i]) / 2;
1177
+ }
1178
+ }
1179
+
1180
+ const barTarget = 150; // Target number of bars for calculation, independent of canvas size
1181
+ const samplesPerBar = Math.floor(channelData.length / barTarget);
1182
+ const peaks = [];
1183
+
1184
+ for (let i = 0; i < barTarget; i++) {
1185
+ let maxPeak = 0;
1186
+ const start = i * samplesPerBar;
1187
+ const end = Math.min(start + samplesPerBar, channelData.length);
1188
+
1189
+ for (let j = start; j < end; j++) {
1190
+ const value = Math.abs(channelData[j]);
1191
+ if (value > maxPeak) {
1192
+ maxPeak = value;
1193
+ }
1194
+ }
1195
+ peaks.push(maxPeak); // Store raw peak
1196
+ }
1197
+ audioPeaks = peaks;
1198
+ drawWaveform(0); // Draw initial waveform
1199
+ }
1200
+
1201
  // Update custom player UI (play/pause icon, time, progress)
1202
  function updatePlayerUI() {
1203
  if (hiddenAudioPlayer.paused || hiddenAudioPlayer.ended) {
1204
  playIcon.style.display = 'block';
1205
  pauseIcon.style.display = 'none';
1206
+ playPauseBtn.setAttribute('aria-label', 'پخش');
1207
  } else {
1208
  playIcon.style.display = 'none';
1209
  pauseIcon.style.display = 'block';
1210
+ playPauseBtn.setAttribute('aria-label', 'مکث');
1211
  }
1212
 
1213
  const currentTime = hiddenAudioPlayer.currentTime;
1214
  const duration = hiddenAudioPlayer.duration;
1215
 
1216
  audioCurrentTimeSpan.textContent = formatTime(currentTime);
1217
+ audioWaveformCanvas.parentNode.setAttribute('aria-valuenow', isFinite(duration) && duration > 0 ? Math.floor((currentTime / duration) * 100) : 0);
1218
+
1219
 
1220
  if (isFinite(duration) && duration > 0) {
1221
  audioTotalTimeSpan.textContent = formatTime(duration);
1222
  const progressRatio = currentTime / duration;
1223
+ drawWaveform(progressRatio);
1224
  } else {
1225
  audioTotalTimeSpan.textContent = '0:00';
1226
+ drawWaveform(0);
1227
  }
1228
  }
1229
 
1230
+ // --- STATE MANAGEMENT ---
1231
+ function showLoadingState() {
1232
+ audioPlayerContent.style.display = 'none';
1233
+ outputSection.classList.remove('has-content');
1234
+ statusMessage.style.display = 'none';
1235
+ loadingAnimationWrapper.style.display = 'flex';
1236
+ generateBtn.disabled = true;
1237
+ generateBtn.innerHTML = `
1238
+ <svg class="spinner" aria-hidden="true" role="status" fill="currentColor" viewBox="0 0 100 101">
1239
+ <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="#E5E7EB"/>
1240
+ <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0492C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentColor"/>
1241
+ </svg>
1242
+ <span class="button-text">در حال پردازش...</span>
1243
+ `;
1244
+ }
1245
+
1246
+ function showResultState(isSuccess, message = '', audioUrl = null) {
1247
+ loadingAnimationWrapper.style.display = 'none';
1248
+ if (isSuccess) {
1249
+ statusMessage.style.display = 'none';
1250
+ audioPlayerContent.style.display = 'flex';
1251
+ outputSection.classList.add('has-content');
1252
+ hiddenAudioPlayer.src = audioUrl;
1253
+ hiddenAudioPlayer.load(); // Reload to trigger loadedmetadata
1254
+ // Play is handled by loadedmetadata listener after waveform processing
1255
+ } else {
1256
+ statusMessage.textContent = message || 'خطایی رخ داد. لطفاً دوباره تلاش کنید.';
1257
+ statusMessage.classList.add('error');
1258
+ statusMessage.style.display = 'block';
1259
+ audioPlayerContent.style.display = 'none';
1260
+ outputSection.classList.remove('has-content');
1261
+ hiddenAudioPlayer.pause();
1262
+ hiddenAudioPlayer.src = '';
1263
+ audioPeaks = []; // Clear waveform data
1264
+ drawWaveform(0); // Clear canvas
1265
+ audioBufferCache = null; // Clear cached buffer
1266
  }
1267
+ generateBtn.disabled = false;
1268
+ generateBtn.innerHTML = '<span class="button-text">✨ تولید صدا با آلفا</span>';
1269
+ }
1270
 
1271
+ // --- SIMULATED BACKEND API CALL ---
1272
+ // THIS IS A MOCK FUNCTION. REPLACE IT WITH YOUR ACTUAL BACKEND API CALL!
1273
+ async function simulateBackendApiCall(text, prompt, speakerId, temperature) {
1274
+ console.log("Simulating API call with:", { text, prompt, speakerId, temperature });
1275
+ return new Promise((resolve, reject) => {
1276
+ const delay = 3000 + Math.random() * 2000; // Simulate network delay
1277
+ setTimeout(() => {
1278
+ if (Math.random() > 0.1) { // Simulate 90% success rate
1279
+ // In a real application, your backend would return the URL to the generated audio
1280
+ // after securely processing the request and potentially deducting credits.
1281
+ const mockAudioUrls = [
1282
+ "https://file-examples.com/storage/fe395e28a56247c413b5e43/2017/11/file_example_MP3_700KB.mp3",
1283
+ "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
1284
+ "https://www.mfiles.co.uk/mp3-midi/test-mp3.mp3", // Shorter for quick test
1285
+ "https://www.kozco.com/tech/piano2-mp3.mp3"
1286
+ ];
1287
+ const randomUrl = mockAudioUrls[Math.floor(Math.random() * mockAudioUrls.length)];
1288
+ resolve({ audioUrl: randomUrl, message: 'صدا با موفقیت تولید شد!' });
1289
+ } else {
1290
+ reject(new Error('متاسفانه، سرور در حال حاضر مشغول است یا خطایی رخ داده. لطفا لحظاتی دیگر تلاش کنید.'));
1291
+ }
1292
+ }, delay);
1293
+ });
1294
+ }
1295
 
1296
+ // --- EVENT HANDLERS ---
 
1297
 
1298
+ textInput.addEventListener('input', () => {
1299
+ const currentLength = textInput.value.length;
1300
+ charCountSpan.textContent = currentLength.toLocaleString('fa-IR');
 
 
1301
 
1302
+ if (currentLength > MAX_CHARS) {
1303
+ charCountSpan.classList.add('error');
1304
+ charErrorMessage.textContent = `متن شما ${currentLength - MAX_CHARS} نویسه بیشتر از حد مجاز است!`;
1305
+ charErrorMessage.style.display = 'inline';
1306
+ generateBtn.disabled = true; // Disable button if over limit
1307
+ } else {
1308
+ charCountSpan.classList.remove('error');
1309
+ charErrorMessage.style.display = 'none';
1310
+ generateBtn.disabled = false; // Re-enable if within limit (and other conditions met)
1311
+ }
1312
+ // Also check if text is empty to disable button
1313
+ if (currentLength === 0) {
1314
+ generateBtn.disabled = true;
1315
+ }
1316
+ });
1317
+
1318
+ tempSlider.addEventListener('input', () => { tempValueSpan.textContent = tempSlider.value; });
1319
+
1320
+ changeSpeakerBtn.addEventListener('click', () => {
1321
+ createSpeakerCardsInModal();
1322
+ showModal(speakerModal);
1323
+ });
1324
+ selectedSpeakerCard.addEventListener('click', () => {
1325
+ createSpeakerCardsInModal();
1326
+ showModal(speakerModal);
1327
+ });
1328
+
1329
+ tempInfoIcon.addEventListener('click', () => showModal(infoModal));
1330
+ tempInfoIcon.addEventListener('keydown', (e) => {
1331
+ if (e.key === 'Enter' || e.key === ' ') {
1332
+ e.preventDefault();
1333
+ showModal(infoModal);
1334
+ }
1335
+ });
1336
+
1337
+ // General Modal Close Listeners (for all modals)
1338
+ document.querySelectorAll('.modal-overlay').forEach(overlay => {
1339
+ overlay.addEventListener('click', (e) => {
1340
+ if (e.target === overlay) {
1341
+ hideModal(overlay);
1342
  }
1343
+ });
1344
+ });
1345
+ document.querySelectorAll('.close-modal-btn').forEach(button => {
1346
+ button.addEventListener('click', () => {
1347
+ const modalId = button.dataset.modalId;
1348
+ if (modalId) {
1349
+ hideModal(document.getElementById(modalId));
1350
+ }
1351
+ });
1352
+ });
1353
+ document.addEventListener('keydown', (e) => {
1354
+ if (e.key === 'Escape') {
1355
+ document.querySelectorAll('.modal-overlay.visible').forEach(hideModal);
1356
  }
1357
+ });
 
 
1358
 
1359
+ // Audio Player Controls
1360
  playPauseBtn.addEventListener('click', () => {
1361
  if (hiddenAudioPlayer.paused) {
1362
  hiddenAudioPlayer.play();
 
1374
  hiddenAudioPlayer.muted = !hiddenAudioPlayer.muted;
1375
  volumeHighIcon.style.display = hiddenAudioPlayer.muted ? 'none' : 'block';
1376
  volumeMuteIcon.style.display = hiddenAudioPlayer.muted ? 'block' : 'none';
1377
+ volumeBtn.setAttribute('aria-label', hiddenAudioPlayer.muted ? 'فعال کردن صدا' : 'بی‌صدا کردن صدا');
1378
  });
1379
  speedBtn.addEventListener('click', () => {
1380
  currentPlaybackSpeedIndex = (currentPlaybackSpeedIndex + 1) % playbackSpeeds.length;
 
1383
  speedBtn.textContent = `${newSpeed}x`;
1384
  });
1385
 
1386
+ // Audio Player Events
1387
  hiddenAudioPlayer.addEventListener('timeupdate', updatePlayerUI);
1388
  hiddenAudioPlayer.addEventListener('loadedmetadata', async () => {
1389
+ // Ensure AudioContext is initialized for waveform processing
1390
  if (!audioContext) {
1391
  audioContext = new (window.AudioContext || window.webkitAudioContext)();
1392
  }
1393
+ if (!audioBufferCache) { // Only decode if not already cached
1394
+ try {
1395
+ const response = await fetch(hiddenAudioPlayer.src);
1396
+ if (!response.ok) throw new Error(`Failed to fetch audio: ${response.statusText}`);
1397
+ const arrayBuffer = await response.arrayBuffer();
1398
+ audioBufferCache = await audioContext.decodeAudioData(arrayBuffer);
1399
+ await processAudioForWaveform(audioBufferCache);
1400
+ } catch (e) {
1401
+ console.error("Error decoding audio for waveform:", e);
1402
+ audioPeaks = [];
1403
+ audioBufferCache = null;
1404
+ statusMessage.textContent = 'خطا در بارگذاری موج صوتی.';
1405
+ statusMessage.classList.add('error');
1406
+ }
1407
+ } else {
1408
+ await processAudioForWaveform(audioBufferCache); // Redraw if cached
1409
  }
1410
  updatePlayerUI(); // Update UI with total duration and initial state
1411
+ if (!hiddenAudioPlayer.paused) { // If it was playing when metadata loaded
1412
+ hiddenAudioPlayer.play().catch(e => console.error("Autoplay failed:", e)); // Attempt to play
1413
+ }
1414
  });
1415
  hiddenAudioPlayer.addEventListener('play', updatePlayerUI);
1416
  hiddenAudioPlayer.addEventListener('pause', updatePlayerUI);
 
1424
  window.addEventListener('resize', () => {
1425
  clearTimeout(resizeTimeout);
1426
  resizeTimeout = setTimeout(() => {
1427
+ if (outputSection.classList.contains('has-content') && audioBufferCache) {
1428
+ // Re-draw waveform if audio is loaded and output section is visible
1429
+ drawWaveform(hiddenAudioPlayer.currentTime / hiddenAudioPlayer.duration);
 
 
 
1430
  }
1431
  }, 250); // Debounce resize events
1432
  });
1433
 
1434
 
1435
+ // --- FORM SUBMISSION ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1436
  async function generateAudio(event) {
1437
  event.preventDefault();
 
1438
 
1439
+ const text = textInput.value.trim();
1440
+ if (!text) {
1441
+ showResultState(false, 'لطفاً متن مورد نظر خود را وارد کنید.');
1442
  return;
1443
  }
1444
  if (text.length > MAX_CHARS) {
1445
  showResultState(false, `خطا: طول متن بیش از ${MAX_CHARS.toLocaleString('fa-IR')} نویسه است.`);
1446
  return;
1447
  }
1448
+ // Simulate credit deduction
1449
+ if (currentCredits < text.length) {
1450
+ showResultState(false, `اعتبار شما برای این درخواست کافی نیست. شما ${currentCredits.toLocaleString('fa-IR')} کاراکتر و این متن ${text.length.toLocaleString('fa-IR')} کاراکتر است.`);
1451
+ return;
1452
+ }
1453
 
1454
+ showLoadingState();
1455
+ statusMessage.classList.remove('error'); // Clear previous error styling
1456
+
1457
+ const promptVal = promptInput.value.trim();
1458
  const temperatureVal = parseFloat(tempSlider.value);
1459
  const selectedSpeakerVal = selectedSpeakerIdStorage.value;
 
 
 
 
 
 
 
 
1460
 
1461
  try {
1462
+ // Call the simulated backend API
1463
+ const result = await simulateBackendApiCall(text, promptVal, selectedSpeakerVal, temperatureVal);
 
 
 
1464
 
1465
+ // If successful, deduct credits (mock)
1466
+ currentCredits -= text.length;
1467
+ creditsLeftSpan.textContent = currentCredits.toLocaleString('fa-IR');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1468
 
1469
+ showResultState(true, 'صدا با موفقیت تولید شد!', result.audioUrl);
1470
  } catch (error) {
1471
  console.error('خطا در فرآیند تولید صدا:', error);
1472
+ showResultState(false, `${error.message || 'خطای ناشناخته در تولید صدا.'}`);
1473
  }
1474
  }
1475
 
1476
  // Initial setup
1477
  updateSelectedSpeakerDisplay(selectedSpeakerIdStorage.value || speakers[0].id);
1478
  form.addEventListener('submit', generateAudio);
1479
+ textInput.dispatchEvent(new Event('input')); // Trigger char counter on load
1480
+ showResultState(false, 'صدای تولید شده در اینجا نمایش داده خواهد شد.');
 
 
1481
  });
1482
  </script>
1483
  </body>