| الان تبدیل متن به صدا درست داره کار میکنه میخواستم یک صفحه جدا به اسم ساخت پادکست داشته باشم الان یک اسپیس داخل عاگینک فیس دارم اما اینترنت ملی شده و عاگینک فیس در دسترس نیست . میخواستم مثل بقیه به رانفلر اضافه کنم اینم کد های اسپیس پادکست من import os |
| import json |
| import time |
| import io |
| import threading |
| import uuid |
| import requests |
| import re |
| import logging |
| import random |
| import base64 |
| import atexit |
| import concurrent.futures |
| from datetime import datetime, timedelta |
| from itertools import cycle |
| from flask import Flask, request, jsonify, render_template, send_file |
| from flask_cors import CORS |
| from pydub import AudioSegment |
| from huggingface_hub import HfApi, hf_hub_download |
| from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError |
|
|
| # |
| CACHE_DIRECTORY = "/tmp/huggingface_cache_ezmary" |
| os.makedirs(CACHE_DIRECTORY, exist_ok=True) |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') |
|
|
| app = Flask(__name__) |
| CORS(app) |
|
|
| # |
| WORKER_URLS = [ |
| "https://opera8-ttspro.hf.space/generate", |
| "https://hamed744-ttspro.hf.space/generate", |
| "https://hamed744-ttspro2.hf.space/generate", |
| ] |
|
|
| # |
| VC_SPACE_URL = "https://ezmarynoori-sada.hf.space" |
|
|
| # |
| tasks = {} |
| tasks_lock = threading.Lock() |
| request_counter = 0 |
| request_counter_lock = threading.Lock() |
| DATASET_REPO = "opera8/Karbaran-rayegan-tedad" |
| DATASET_FILENAME = "usage_data.json" |
| USAGE_LIMIT = 5 |
| HF_TOKEN = os.environ.get("HF_TOKEN") |
| CLEANUP_INTERVAL_SECONDS = 6 * 30 * 24 * 60 * 60 |
| last_cleanup_time = time.time() |
| usage_data_cache = [] |
| cache_lock = threading.Lock() |
| data_changed = threading.Event() |
| api = None |
|
|
| # |
| STANDARD_HEADERS = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", |
| "Accept": "application/json", |
| "Content-Type": "application/json" |
| } |
|
|
| # |
| if not HF_TOKEN: |
| logging.error("CRITICAL: Secret 'HF_TOKEN' not found.") |
| else: |
| api = HfApi(token=HF_TOKEN) |
| logging.info("HfApi initialized.") |
|
|
| def load_initial_data(): |
| global usage_data_cache |
| with cache_lock: |
| if not api: return |
| try: |
| local_path = hf_hub_download( |
| repo_id=DATASET_REPO, filename=DATASET_FILENAME, repo_type="dataset", token=HF_TOKEN, force_download=True, cache_dir=CACHE_DIRECTORY |
| ) |
| with open(local_path, 'r', encoding='utf-8') as f: |
| content = f.read() |
| if content: usage_data_cache = json.loads(content) |
| except (RepositoryNotFoundError, EntryNotFoundError): |
| logging.warning("Dataset not found, creating new.") |
| except Exception as e: |
| logging.error(f"Failed to load data: {e}") |
|
|
| def persist_data_to_hub(): |
| global last_cleanup_time, usage_data_cache |
| with cache_lock: |
| now = time.time() |
| if (now - last_cleanup_time) > CLEANUP_INTERVAL_SECONDS: |
| six_months_ago = now - CLEANUP_INTERVAL_SECONDS |
| usage_data_cache = [u for u in usage_data_cache if u.get('week_start', 0) > six_months_ago] |
| last_cleanup_time = now |
| data_changed.set() |
| |
| if not data_changed.is_set() or not api: return |
| try: |
| data_to_write = list(usage_data_cache) |
| temp_filepath = os.path.join(CACHE_DIRECTORY, "temp_usage_data.json") |
| with open(temp_filepath, 'w', encoding='utf-8') as f: |
| json.dump(data_to_write, f, indent=2, ensure_ascii=False) |
| api.upload_file(path_or_fileobj=temp_filepath, path_in_repo=DATASET_FILENAME, repo_id=DATASET_REPO, repo_type="dataset", commit_message="Update") |
| os.remove(temp_filepath) |
| data_changed.clear() |
| except Exception as e: |
| logging.error(f"Persist failed: {e}") |
|
|
| def background_persister(): |
| while True: |
| time.sleep(10) |
| persist_data_to_hub() |
|
|
| def get_user_ip(): |
| if request.headers.getlist("X-Forwarded-For"): |
| return request.headers.getlist("X-Forwarded-For")[0].split(',')[0].strip() |
| return request.remote_addr |
|
|
| # |
| def call_worker(index, chunk_payload): |
| raw_text = chunk_payload.get("text", "") |
| |
| # پاکسازی متن از براکتها و کاراکترهای مخرب |
| clean_text = re.sub(r'[*_`~#]', '', raw_text) |
| clean_text = re.sub(r'\[.*?\]|\(.*?\)', '', clean_text) |
| clean_text = clean_text.strip() |
| |
| if not clean_text: |
| return index, AudioSegment.silent(duration=500) |
|
|
| use_live = True if len(clean_text) <= 600 else False |
| |
| target_speaker = chunk_payload.get("speaker") |
| is_custom = chunk_payload.get("is_custom", False) |
| actual_speaker_request = "Charon" if is_custom else target_speaker |
|
|
| worker_payload = { |
| "text": clean_text, |
| "speaker": actual_speaker_request, |
| "temperature": chunk_payload.get("temperature", 0.9), |
| "use_live_model": use_live, |
| "retry_limit": 50, |
| "fallback_to_live": True |
| } |
|
|
| max_attempts = 40 |
| |
| for attempt in range(max_attempts): |
| workers = list(WORKER_URLS) |
| random.shuffle(workers) |
| |
| for worker_url in workers: |
| try: |
| logging.info(f"▶️ [قطعه {index+1}] ارسال به {worker_url} (تلاش {attempt+1}/{max_attempts})") |
| response = requests.post(worker_url, json=worker_payload, headers=STANDARD_HEADERS, timeout=120) |
| |
| if response.status_code == 200: |
| logging.info(f"✅ [قطعه {index+1}] با موفقیت ساخته شد.") |
| audio_data = io.BytesIO(response.content) |
| audio_segment = AudioSegment.from_file(audio_data) |
| return index, audio_segment |
| |
| else: |
| logging.warning(f"⚠️ [قطعه {index+1}] ارور {response.status_code} از کارگر دریافت شد. تلاش بعدی...") |
| continue |
| |
| except Exception as e: |
| logging.error(f"❌ [قطعه {index+1}] قطعی ارتباط: {str(e)[:40]}") |
| continue |
| |
| sleep_time = 3 + (attempt * 1.5) + random.uniform(0.5, 2.0) |
| logging.warning(f"🔄 [قطعه {index+1}] کارگرها شلوغ/خطا دادند. استراحت به مدت {sleep_time:.1f} ثانیه...") |
| time.sleep(sleep_time) |
| |
| raise ValueError(f"قطعه {index+1} پس از 40 بار تلاش ساخته نشد.") |
|
|
| # |
| def generate_podcast_in_background(task_id, system_prompt, safety_settings): |
| try: |
| AYA_SPACE_URL = "https://coherelabs-aya-expanse.hf.space/gradio_api" |
| MAX_ATTEMPTS = 50 |
| |
| for attempt in range(MAX_ATTEMPTS): |
| try: |
| session_hash = str(uuid.uuid4())[0:11] |
| payload = { |
| "data": [system_prompt, [], None, None], |
| "event_data": None, "fn_index": 2, "session_hash": session_hash, "trigger_id": 37 |
| } |
|
|
| join_response = requests.post(f"{AYA_SPACE_URL}/queue/join", json=payload, headers=STANDARD_HEADERS, timeout=60) |
| join_response.raise_for_status() |
| |
| stream_url = f"{AYA_SPACE_URL}/queue/data?session_hash={session_hash}" |
| raw_text = None |
| |
| with requests.get(stream_url, stream=True, headers=STANDARD_HEADERS, timeout=120) as resp: |
| for line in resp.iter_lines(): |
| if line: |
| decoded_line = line.decode('utf-8') |
| if decoded_line.startswith('data: '): |
| json_str = decoded_line[6:] |
| try: |
| msg_data = json.loads(json_str) |
| msg_type = msg_data.get('msg') |
| if msg_type == 'process_completed': |
| output = msg_data.get('output', {}).get('data', []) |
| if output and len(output) > 0: |
| updated_history = output[0] |
| if updated_history: |
| raw_text = updated_history[-1][1] |
| elif msg_type == 'queue_full': |
| raise Exception("صف سرور شلوغ است") |
| except json.JSONDecodeError: continue |
|
|
| if not raw_text: raise ValueError("بدون پاسخ از مدل هوش مصنوعی") |
|
|
| json_string = None |
| json_pattern = r"`{3}json\s*(\{.*?\})\s*`{3}" |
| match = re.search(json_pattern, raw_text, re.DOTALL) |
| if match: json_string = match.group(1) |
| else: |
| s_idx = raw_text.find('{') |
| e_idx = raw_text.rfind('}') |
| if s_idx != -1 and e_idx != -1: json_string = raw_text[s_idx:e_idx+1] |
| |
| if not json_string: raise ValueError("No JSON found in response") |
| |
| data = json.loads(json_string) |
| if "script" in data: |
| for t in data["script"]: |
| if "dialogue" in t: |
| t["dialogue"] = re.sub(r'\[.*?\]|\(.*?\)', '', t["dialogue"]).strip() |
|
|
| with tasks_lock: |
| tasks[task_id].update({'status': 'completed', 'data': data}) |
| return |
| |
| except Exception as e: |
| logging.warning(f"AI Attempt {attempt} failed: {e}") |
| time.sleep(2) |
| |
| with tasks_lock: tasks[task_id].update({'status': 'failed', 'error': 'Max retries reached'}) |
|
|
| except Exception as e: |
| with tasks_lock: tasks[task_id].update({'status': 'failed', 'error': str(e)}) |
|
|
| # |
| def generate_full_podcast_audio_background(task_id, prompt, speakers): |
| try: |
| logging.info(f"🚀 [پروژه {task_id}] عملیات ساخت پادکست آغاز شد.") |
| with tasks_lock: |
| tasks[task_id] = {'status': 'writing_script', 'progress': 'در حال نگارش سناریو...'} |
| |
| valid_speaker_ids = [str(s['id']).strip() for s in speakers] |
| default_speaker_id = valid_speaker_ids[0] if valid_speaker_ids else "Charon" |
| # 1. ساخت سناریو |
| spk_text = "\n".join([f"- {s['id']}: {s['name']}" for s in speakers]) |
| system_prompt = f"""Act as a Professional Podcast Producer. |
| Topic: "{prompt}" |
| Speakers Available: |
| {spk_text} |
|
|
| CRITICAL INSTRUCTION: You must create a VERY LONG, in-depth, and highly detailed podcast script. |
| - Do NOT write a short summary. |
| - The conversation must deeply explore the topic. Make it a very long and detailed conversation, as long as necessary to fully cover the topic. |
| - Make the dialogue engaging and informative. You must use two speakers for the podcast, or three if necessary, or whatever number of speakers the user specifies. The podcast should be an engaging exchange, typically between a man and a woman, with both speakers discussing the topic together. |
|
|
| Output ONLY valid JSON. |
| Format: {{"selected_speakers": ["id1", "id2"], "script": [{{"speaker_id": "id1", "dialogue": "..."}}]}} |
| Dialogue rules: Do NOT avoid writing stage directions and emotional cues like laughing or sighing. They are allowed, but the mood/action MUST be placed inside parentheses, for example (laugh) or (sigh).""" |
|
|
| AYA_SPACE_URL = "https://coherelabs-aya-expanse.hf.space/gradio_api" |
| MAX_ATTEMPTS = 50 |
| data = None |
| |
| logging.info("📝 در حال ارتباط با هوش مصنوعی برای نوشتن سناریو...") |
| for attempt in range(MAX_ATTEMPTS): |
| try: |
| session_hash = str(uuid.uuid4())[0:11] |
| payload = {"data": [system_prompt, [], None, None], "event_data": None, "fn_index": 2, "session_hash": session_hash, "trigger_id": 37} |
| join_response = requests.post(f"{AYA_SPACE_URL}/queue/join", json=payload, headers=STANDARD_HEADERS, timeout=60) |
| join_response.raise_for_status() |
| |
| stream_url = f"{AYA_SPACE_URL}/queue/data?session_hash={session_hash}" |
| raw_text = None |
| with requests.get(stream_url, stream=True, headers=STANDARD_HEADERS, timeout=120) as resp: |
| for line in resp.iter_lines(): |
| if line: |
| decoded_line = line.decode('utf-8') |
| if decoded_line.startswith('data: '): |
| json_str = decoded_line[6:] |
| try: |
| msg_data = json.loads(json_str) |
| if msg_data.get('msg') == 'process_completed': |
| output = msg_data.get('output', {}).get('data', []) |
| if output and len(output) > 0 and output[0]: |
| raw_text = output[0][-1][1] |
| except: pass |
| |
| if raw_text: |
| json_string = None |
| json_pattern = r"`{3}json\s*(\{.*?\})\s*`{3}" |
| match = re.search(json_pattern, raw_text, re.DOTALL) |
| if match: json_string = match.group(1) |
| else: |
| s_idx = raw_text.find('{') |
| e_idx = raw_text.rfind('}') |
| if s_idx != -1 and e_idx != -1: json_string = raw_text[s_idx:e_idx+1] |
| if json_string: |
| data = json.loads(json_string) |
| break |
| except Exception as e: |
| time.sleep(2) |
| |
| if not data or "script" not in data: |
| raise ValueError("هوش مصنوعی در نوشتن سناریو با خطا مواجه شد.") |
| |
| for t in data["script"]: |
| if "dialogue" in t: t["dialogue"] = re.sub(r'\[.*?\]|\(.*?\)', '', t["dialogue"]).strip() |
|
|
| script_turns = [t for t in data.get("script", []) if str(t.get("dialogue", "")).strip()] |
| total_turns = len(script_turns) |
| |
| logging.info(f"✅ سناریو استخراج شد! شامل {total_turns} نوبت گفتگو.") |
| |
| with tasks_lock: |
| tasks[task_id] = {'status': 'generating_audio', 'progress': f'در حال تولید صداها (0 از {total_turns} تکمیل شده)'} |
| |
| audio_results = [None] * total_turns |
| completed_count = 0 |
| |
| def process_single_chunk(index, turn_data): |
| # 🔴 سیستم هوشمند جایگزینی گوینده (شبیهسازی کامل رفتار سایت) |
| raw_speaker_id = str(turn_data.get("speaker_id", "")).strip() |
| final_speaker_id = raw_speaker_id |
| |
| if final_speaker_id not in valid_speaker_ids: |
| found = False |
| for v_id in valid_speaker_ids: |
| if final_speaker_id.lower() == v_id.lower(): |
| final_speaker_id = v_id |
| found = True |
| break |
| if not found: |
| logging.warning(f"⚠️ آیدی گوینده نامعتبر '{raw_speaker_id}' تشخیص داده شد. جایگزین شد با '{default_speaker_id}'.") |
| final_speaker_id = default_speaker_id |
|
|
| dialogue = turn_data.get("dialogue") |
| payload = {"text": dialogue, "speaker": final_speaker_id, "temperature": 0.9, "is_custom": False} |
| idx, audio_seg = call_worker(index, payload) |
| if audio_seg is None: |
| raise ValueError(f"خطا در تولید صدای نوبت {index+1}") |
| return idx, audio_seg |
|
|
| batch_size = 7 |
| batches = [script_turns[i:i + batch_size] for i in range(0, total_turns, batch_size)] |
| |
| for batch_index, batch in enumerate(batches): |
| logging.info(f"🔄 شروع پردازش دستهی {batch_index+1} از {len(batches)} (شامل {len(batch)} دیالوگ)...") |
| |
| with concurrent.futures.ThreadPoolExecutor(max_workers=len(batch)) as executor: |
| start_idx = batch_index * batch_size |
| future_to_index = {executor.submit(process_single_chunk, start_idx + i, turn): start_idx + i for i, turn in enumerate(batch)} |
| |
| for future in concurrent.futures.as_completed(future_to_index): |
| try: |
| idx, audio_seg = future.result() |
| audio_results[idx] = audio_seg |
| completed_count += 1 |
| with tasks_lock: |
| tasks[task_id]['progress'] = f'در حال تولید صداها ({completed_count} از {total_turns} تکمیل شده)' |
| except Exception as exc: |
| logging.error(f"❌ خطای بحرانی: {str(exc)}") |
| raise Exception(str(exc)) |
| |
| logging.info(f"✔️ دستهی {batch_index+1} با موفقیت تمام شد.") |
|
|
| logging.info("✂️ تمام قطعات ساخته شد. در حال ادغام (میکس) فایلها...") |
| combined_audio = AudioSegment.empty() |
| for seg in audio_results: |
| if seg: |
| combined_audio += seg |
|
|
| with tasks_lock: |
| tasks[task_id] = {'status': 'mixing', 'progress': 'در حال میکس نهایی و ذخیره پادکست...'} |
| |
| filename = f"full_podcast_{task_id}.mp3" |
| filepath = os.path.join(CACHE_DIRECTORY, filename) |
| |
| output_buffer = io.BytesIO() |
| combined_audio.export(output_buffer, format="mp3") |
| output_buffer.seek(0) |
| |
| with open(filepath, 'wb') as f: |
| f.write(output_buffer.read()) |
| |
| logging.info(f"🎉 پادکست نهایی آماده شد! نام فایل: {filename}") |
| with tasks_lock: |
| tasks[task_id] = {'status': 'completed', 'filename': filename, 'progress': 'پادکست شما آماده است!'} |
|
|
| except Exception as e: |
| logging.error(f"❌ پردازش کل پادکست با شکست مواجه شد: {str(e)}") |
| with tasks_lock: |
| tasks[task_id] = {'status': 'failed', 'error': str(e)} |
|
|
| # |
| def process_voice_conversion(tts_audio_io, ref_audio_base64): |
| try: |
| tts_audio_io.seek(0) |
| if "," in ref_audio_base64: ref_audio_base64 = ref_audio_base64.split(",")[1] |
| ref_bytes = base64.b64decode(ref_audio_base64) |
| |
| files = { |
| 'source_audio': ('source.wav', tts_audio_io, 'audio/wav'), |
| 'ref_audio': ('ref.wav', io.BytesIO(ref_bytes), 'audio/wav') |
| } |
| res = requests.post(f"{VC_SPACE_URL}/upload", files=files, headers=STANDARD_HEADERS, timeout=120) |
| if res.status_code != 200: raise Exception(f"VC Upload Failed: {res.text}") |
| |
| job_data = res.json() |
| for _ in range(120): |
| time.sleep(4) |
| chk = requests.post(f"{VC_SPACE_URL}/check_status", json=job_data, headers=STANDARD_HEADERS, timeout=30) |
| if chk.status_code == 200: |
| stat = chk.json() |
| if stat.get("status") == "completed": |
| filename = stat.get("filename") |
| dl = requests.get(f"{VC_SPACE_URL}/download/{filename}", headers=STANDARD_HEADERS) |
| if dl.status_code == 200: return io.BytesIO(dl.content) |
| else: raise Exception("VC Download Failed") |
| elif stat.get("status") == "failed": |
| raise Exception(f"VC Remote Failed: {stat.get('detail', 'Unknown error')}") |
| |
| raise Exception("VC Timeout (Processing took too long)") |
| except Exception as e: |
| logging.error(f"VC Error: {e}") |
| return None |
|
|
| # |
| @app.route('/') |
| def index(): |
| return render_template('index.html') |
|
|
| @app.route('/api/check-credit', methods=['POST']) |
| def check_credit(): |
| data = request.get_json() |
| fingerprint = data.get('fingerprint') |
| if not fingerprint: return jsonify({"status": "error"}), 400 |
| with cache_lock: |
| ip = get_user_ip() |
| now = time.time() |
| week_ago = now - (7*24*60*60) |
| |
| user = next((u for u in usage_data_cache if u.get('fingerprint') == fingerprint), None) |
| user = user or next((u for u in usage_data_cache if ip in u.get('ips', [])), None) |
| |
| limit_reached = False |
| remaining = USAGE_LIMIT |
| reset_ts = 0 |
| |
| if user: |
| if user.get('week_start', 0) < week_ago: |
| user['count'] = 0 |
| user['week_start'] = now |
| data_changed.set() |
| |
| remaining = USAGE_LIMIT - user.get('count', 0) |
| if remaining <= 0: |
| limit_reached = True |
| remaining = 0 |
| reset_ts = user.get('week_start', now) + (7*24*60*60) |
| |
| return jsonify({"credits_remaining": remaining, "limit_reached": limit_reached, "reset_timestamp": reset_ts}) |
|
|
| @app.route('/api/use-credit', methods=['POST']) |
| def use_credit(): |
| data = request.get_json() |
| fingerprint = data.get('fingerprint') |
| with cache_lock: |
| ip = get_user_ip() |
| now = time.time() |
| week_ago = now - (7*24*60*60) |
| user = next((u for u in usage_data_cache if u.get('fingerprint') == fingerprint), None) |
| user = user or next((u for u in usage_data_cache if ip in u.get('ips', [])), None) |
| |
| if user: |
| if user.get('week_start', 0) < week_ago: |
| user['count'] = 0 |
| user['week_start'] = now |
| if user['count'] >= USAGE_LIMIT: |
| return jsonify({"status": "limit"}), 429 |
| user['count'] += 1 |
| if ip not in user['ips']: user['ips'].append(ip) |
| else: |
| user = {"fingerprint": fingerprint, "ips": [ip], "count": 1, "week_start": now} |
| usage_data_cache.append(user) |
| |
| data_changed.set() |
| return jsonify({"status": "success", "credits_remaining": USAGE_LIMIT - user['count']}) |
|
|
| @app.route('/api/create-full-podcast', methods=['POST']) |
| def create_full_podcast(): |
| try: |
| data = request.get_json() |
| prompt = data.get('prompt') |
| speakers = data.get('available_speakers') |
| if not prompt or not speakers: return jsonify({"error": "Bad request"}), 400 |
| |
| task_id = str(uuid.uuid4()) |
| with tasks_lock: tasks[task_id] = {'status': 'pending'} |
| |
| safety = [{"category": c, "threshold": "BLOCK_NONE"} for c in ["HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT"]] |
| spk_text = "\n".join([f"- {s['id']}: {s['name']}" for s in speakers]) |
| |
| sys_prompt = f"""Act as a Professional Podcast Producer.\nTopic: "{prompt}"\nSpeakers Available:\n{spk_text}\nCRITICAL INSTRUCTIONS:\n1. Create a VERY LONG, in-depth podcast script.\n2. Keep EVERY dialogue line SHORT. Break long speeches into multiple consecutive turns for the same speaker.\n3. NO stage directions, NO emojis, NO brackets. Plain text ONLY.\n\nOutput ONLY valid JSON.\nFormat: {{"selected_speakers": ["id1", "id2"], "script": [{{"speaker_id": "id1", "dialogue": "..."}}]}}""" |
| |
| threading.Thread(target=generate_podcast_in_background, args=(task_id, sys_prompt, safety)).start() |
| return jsonify({"task_id": task_id}), 202 |
| except Exception as e: |
| return jsonify({"error": str(e)}), 500 |
|
|
| @app.route('/api/podcast-status/<task_id>', methods=['GET']) |
| def podcast_status(task_id): |
| with tasks_lock: |
| return jsonify(tasks.get(task_id, {'status': 'not_found'})), 200 |
|
|
| @app.route('/api/auto-podcast', methods=['POST']) |
| def auto_podcast(): |
| try: |
| data = request.get_json() |
| prompt = data.get('prompt') |
| speakers = data.get('available_speakers') |
| if not prompt or not speakers: return jsonify({"error": "Bad request"}), 400 |
| |
| task_id = str(uuid.uuid4()) |
| with tasks_lock: tasks[task_id] = {'status': 'pending', 'progress': 'در حال ورود به صف پردازش...'} |
| |
| threading.Thread(target=generate_full_podcast_audio_background, args=(task_id, prompt, speakers)).start() |
| return jsonify({"task_id": task_id}), 202 |
| except Exception as e: |
| return jsonify({"error": str(e)}), 500 |
|
|
| @app.route('/api/auto-podcast-status/<task_id>', methods=['GET']) |
| def auto_podcast_status(task_id): |
| with tasks_lock: |
| return jsonify(tasks.get(task_id, {'status': 'not_found'})), 200 |
|
|
| @app.route('/api/download-podcast/<filename>', methods=['GET']) |
| def download_podcast(filename): |
| filepath = os.path.join(CACHE_DIRECTORY, filename) |
| if os.path.exists(filepath): |
| return send_file(filepath, mimetype="audio/mp3", as_attachment=True) |
| return jsonify({"error": "File not found"}), 404 |
|
|
| @app.route('/api/generate', methods=['POST']) |
| def generate_audio_route(): |
| try: |
| data = request.get_json() |
| if not data: return jsonify({"error": "No data"}), 400 |
| |
| text = data.get("text", "") |
| speaker = data.get("speaker") |
| temperature = data.get("temperature", 0.9) |
| ref_base64 = data.get("ref_audio_base64") |
| |
| if not text: return jsonify({"error": "Text empty"}), 400 |
| |
| is_custom = bool(speaker.startswith("custom_") and ref_base64) |
| payload = {"text": text, "speaker": speaker, "temperature": temperature, "is_custom": is_custom} |
| |
| idx, audio_seg = call_worker(0, payload) |
| if audio_seg is None: return jsonify({"error": "Worker generation failed"}), 503 |
| |
| final_buffer = io.BytesIO() |
| audio_seg.export(final_buffer, format="wav") |
| final_buffer.seek(0) |
| |
| if is_custom: |
| logging.info("Starting Custom VC...") |
| vc_out = process_voice_conversion(final_buffer, ref_base64) |
| if vc_out: return send_file(vc_out, mimetype="audio/wav", as_attachment=True, download_name=f"vc_{uuid.uuid4()}.wav") |
| else: return jsonify({"error": "Voice Conversion failed"}), 500 |
| |
| return send_file(final_buffer, mimetype="audio/wav", as_attachment=True, download_name=f"gen_{uuid.uuid4()}.wav") |
|
|
| except Exception as e: |
| logging.error(f"Generate route error: {e}") |
| return jsonify({"error": str(e)}), 500 |
|
|
| # |
| load_initial_data() |
| threading.Thread(target=background_persister, daemon=True).start() |
| atexit.register(persist_data_to_hub) |
|
|
| if __name__ == '__main__': |
| port = int(os.environ.get('PORT', 7860)) |
| app.run(host='0.0.0.0', port=port) |
| این یکی Flask |
| Flask-Cors |
| gunicorn |
| openai |
| huggingface-hub |
| requests |
| pydubاین یکی Use an official Python runtime as a parent image |
| FROM python:3.9-slim |
|
|
| # Set the working directory in the container |
| WORKDIR /app |
|
|
| # Install ffmpeg for pydub (audio processing) |
| RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| .speaker-card:hover:not(.disabled) { transform: translateY(-8px); } |
| .speaker-card .speaker-visual { border: 3px solid transparent; border-radius: 18px; overflow: hidden; box-shadow: var( |
| .speaker-card:hover:not(.disabled) .speaker-visual { box-shadow: var( |
| .speaker-card img { width: 100%; height: 130px; object-fit: cover; display: block; background-color: #e0e0e0; border-radius: 14px; } |
| .speaker-card .speaker-name { padding: 0.8rem 0.4rem 0.2rem; font-weight: 600; font-size: 0.95em; color: var( |
| .speaker-card.disabled { opacity: 0.4; cursor: not-allowed; } |
| .custom-select-trigger { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: var( |
| .custom-select-trigger:hover { border-color: var( |
| .custom-select-trigger img { width: 40px; height: 40px; border-radius: 10px; object-fit: cover; } |
| .custom-select-trigger span { font-weight: 600; flex-grow: 1; } |
| .custom-select-trigger .arrow { margin-right: auto; width: 20px; height: 20px; color: var( |
| .custom-select-container.open .arrow { transform: rotate(180deg); } |
| .custom-select-options { position: absolute; top: calc(100% + 8px); left: 0; width: 100%; background: var( |
| .custom-select-container.open .custom-select-options { opacity: 1; transform: translateY(0); pointer-events: auto; } |
| .custom-select-option { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; cursor: pointer; } |
| .custom-select-option:hover { background-color: var( |
| .custom-select-option img { width: 40px; height: 40px; border-radius: 10px; object-fit: cover; } |
| .custom-select-option span { font-weight: 500; } |
| .no-speaker-option { padding: 1rem; text-align: center; color: var( |
| .ai-script-btn { background: transparent; border: 1px solid var( |
| .ai-script-btn:hover:not(:disabled) { background: var( |
| #ai-modal .modal-dialog { max-width: 480px; padding: 32px 36px; min-height: 480px; display: flex; align-items: center; } |
| #ai-modal .modal-content-wrapper, #ai-modal .modal-loading-wrapper { width: 100%; transition: opacity 0.4s ease-in-out, transform 0.4s ease-in-out; } |
| #ai-modal .modal-content-wrapper:not(.active), #ai-modal .modal-loading-wrapper:not(.active) { opacity: 0; transform: scale(0.95); pointer-events: none; position: absolute; right: 36px; left: 36px; } |
| #ai-modal .modal-loading-wrapper { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 40px; } |
| #ai-modal .loader-animation { width: 120px; height: 120px; position: relative; display: flex; align-items: center; justify-content: center; } |
| #ai-modal .core-pulse { width: 25px; height: 25px; background-color: var( |
| #ai-modal .ai-orbit { position: absolute; border-radius: 50%; border: 2px dashed var( |
| #ai-modal .ai-orbit:nth-child(2) { width: 70px; height: 70px; animation-duration: 8s; animation-direction: reverse; } |
| #ai-modal .ai-orbit:nth-child(3) { width: 110px; height: 110px; animation-duration: 12s; } |
| #ai-modal .ai-orbit::before { content: ''; position: absolute; width: 10px; height: 10px; border-radius: 50%; background-color: var( |
| #ai-modal .ai-orbit:nth-child(2)::before { background-color: var( |
| #ai-modal .ai-orbit:nth-child(3)::before { background-color: var( |
| #loading-text-ai { font-size: 18px; font-weight: 600; background-image: linear-gradient(100deg, var( |
| #ai-modal .modal-header { padding-bottom: 0; border-bottom: none; margin-bottom: 0; } |
| #ai-modal .modal-header h2 { font-size: 28px; font-weight: 800; } |
| #ai-modal .modal-body p { color: var( |
| #ai-modal textarea { width: 100%; min-height: 150px; padding: 16px; border: 2px solid var( |
| #ai-modal textarea:focus { outline: none; border-color: var( |
| #ai-modal .modal-footer { margin-top: 28px; padding-top: 0; border-top: none; } |
| #generate-script-ai-btn { width: 100%; padding: 16px 24px; font-family: var( |
| #generate-script-ai-btn:hover:not(:disabled) { background-position: right center; } |
| #ai-status-message { margin: 1rem 0 0 0; text-align: center; display: none; } |
| #ai-status-message.error { color: #c53030; font-weight: 700; } |
| .confirm-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(18, 24, 38, 0.6); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 1001; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; } |
| .confirm-modal-overlay.visible { opacity: 1; pointer-events: auto; } |
| .confirm-modal-dialog { background: var( |
| .confirm-modal-overlay.visible .confirm-modal-dialog { transform: scale(1); } |
| .confirm-modal-dialog h3 { font-size: 1.5em; font-weight: 800; margin: 0 0 1rem 0; color: var( |
| .confirm-modal-dialog p { color: var( |
| .confirm-modal-actions { display: flex; gap: 1rem; justify-content: center; } |
| .confirm-btn, .cancel-btn { font-family: var( |
| .confirm-btn { background-color: #e53e3e; color: white; box-shadow: 0 4px 10px -2px rgba(229, 62, 62, 0.4); } |
| .confirm-btn:hover { background-color: #c53030; transform: translateY(-2px); box-shadow: 0 6px 14px -3px rgba(229, 62, 62, 0.5); } |
| .cancel-btn { background-color: var( |
| .cancel-btn:hover { background-color: var( |
| #countdown-timer { display: none; margin-top: 20px; padding: 15px; background-color: #fff9e6; border-radius: 8px; border: 1px solid #ffeeba; animation: fadeIn 0.5s ease; } |
| .timer-content { display: flex; align-items: center; justify-content: center; gap: 12px; } |
| .clock-icon { width: 28px; height: 28px; animation: spin 20s linear infinite; flex-shrink: 0; } |
| #time-left { font-size: 1.2rem; font-weight: 700; color: #856404; } |
| .timer-text { font-size: 0.9rem; color: #856404; } |
| @media (max-width: 768px) { .script-turn { flex-direction: column; } .remove-turn-btn { align-self: flex-end; margin-top: -1.5rem; } .turn-speaker-selector { width: 100%; } } |
|
|
| |
| .custom-voice-btn-container { |
| grid-column: span 2; |
| display: flex; |
| } |
|
|
| .add-custom-voice-btn { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 15px; |
| width: 100%; |
| padding: 1rem; |
| background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%); |
| border: 2px dashed #cbd5e0; |
| border-radius: 20px; |
| cursor: pointer; |
| transition: all 0.3s ease; |
| position: relative; |
| overflow: hidden; |
| height: 100%; |
| box-sizing: border-box; |
| } |
|
|
| .add-custom-voice-btn:hover { |
| border-color: var( |
| background: #fff; |
| box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1); |
| transform: translateY(-3px); |
| } |
|
|
| .custom-voice-icon-wrapper { |
| width: 50px; |
| height: 50px; |
| background: var( |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: var( |
| font-size: 1.5rem; |
| flex-shrink: 0; |
| } |
|
|
| .custom-voice-text { |
| text-align: right; |
| } |
|
|
| .custom-voice-text h4 { |
| margin: 0 0 3px 0; |
| font-size: 1.1rem; |
| font-weight: 800; |
| color: var( |
| } |
|
|
| .custom-voice-text p { |
| margin: 0; |
| font-size: 0.85rem; |
| color: var( |
| line-height: 1.4; |
| } |
|
|
| |
| .custom-speaker-card .speaker-visual { |
| border: 3px solid transparent; |
| height: 130px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| background-color: transparent; |
| } |
|
|
| .custom-speaker-card .custom-voice-avatar { |
| margin: 0; |
| } |
|
|
| |
| .card-menu-btn { |
| position: absolute; |
| top: 8px; |
| left: 8px; |
| background: rgba(255, 255, 255, 0.8); |
| border: none; |
| border-radius: 50%; |
| width: 28px; |
| height: 28px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| z-index: 10; |
| font-size: 18px; |
| line-height: 1; |
| color: var( |
| transition: all 0.2s; |
| } |
| .card-menu-btn:hover { |
| background: #fff; |
| color: var( |
| transform: scale(1.1); |
| } |
| .card-menu-dropdown { |
| position: absolute; |
| top: 40px; |
| left: 8px; |
| background: #fff; |
| border-radius: 12px; |
| box-shadow: 0 4px 15px rgba(0,0,0,0.15); |
| padding: 5px; |
| z-index: 20; |
| display: none; |
| min-width: 140px; |
| border: 1px solid var( |
| } |
| .card-menu-dropdown.active { |
| display: block; |
| animation: fadeIn 0.2s ease; |
| } |
| .card-menu-item { |
| padding: 8px 12px; |
| font-size: 0.9em; |
| color: var( |
| cursor: pointer; |
| border-radius: 8px; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| transition: background 0.2s; |
| } |
| .card-menu-item:hover { |
| background-color: var( |
| color: var( |
| } |
| |
| .card-menu-item.delete-item { |
| color: #e53e3e; |
| } |
| .card-menu-item.delete-item:hover { |
| background-color: #fff5f5; |
| color: #c53030; |
| } |
|
|
| .audio-preview-mini { |
| position: absolute; |
| bottom: 5px; |
| right: 5px; |
| width: 24px; |
| height: 24px; |
| background: white; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: var( |
| box-shadow: 0 2px 5px rgba(0,0,0,0.2); |
| z-index: 2; |
| } |
| |
| @keyframes pulse-soft { |
| 0% { box-shadow: 0 0 0 0 rgba(74, 108, 250, 0.4); } |
| 70% { box-shadow: 0 0 0 10px rgba(74, 108, 250, 0); } |
| 100% { box-shadow: 0 0 0 0 rgba(74, 108, 250, 0); } |
| } |
| .add-custom-voice-btn:hover .custom-voice-icon-wrapper { |
| animation: pulse-soft 2s infinite; |
| } |
|
|
| |
| .glass-vertical-card { |
| display: none; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| text-align: center; |
| max-width: 320px; |
| width: 100%; |
| padding: 24px 20px; |
| background: rgba(255, 255, 255, 0.75); |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| border-radius: 24px; |
| border: 1px solid rgba(255, 255, 255, 0.9); |
| box-shadow: 0 15px 35px -5px rgba(67, 56, 202, 0.15), 0 5px 15px -5px rgba(0, 0, 0, 0.05); |
| position: relative; |
| overflow: hidden; |
| animation: popIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; |
| opacity: 0; |
| transform: scale(0.9); |
| margin-top: 2rem; |
| } |
|
|
| @keyframes popIn { |
| to { opacity: 1; transform: scale(1); } |
| } |
|
|
| .icon-circle-wrapper { |
| width: 60px; |
| height: 60px; |
| border-radius: 50%; |
| background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: white; |
| margin-bottom: 16px; |
| box-shadow: 0 8px 20px rgba(99, 102, 241, 0.35); |
| position: relative; |
| z-index: 2; |
| } |
|
|
| .icon-circle-wrapper::after { |
| content: ''; |
| position: absolute; |
| top: 0; left: 0; right: 0; bottom: 0; |
| border-radius: 50%; |
| border: 2px solid #8b5cf6; |
| opacity: 0; |
| animation: ripple 2s infinite cubic-bezier(0, 0.2, 0.8, 1); |
| z-index: -1; |
| } |
|
|
| @keyframes ripple { |
| 0% { transform: scale(1); opacity: 0.6; } |
| 100% { transform: scale(1.6); opacity: 0; } |
| } |
|
|
| #ai-progress-percent { |
| font-family: var( |
| font-weight: 800; |
| font-size: 1.2rem; |
| letter-spacing: 1px; |
| text-shadow: 0 2px 4px rgba(0,0,0,0.2); |
| } |
|
|
| .status-title { |
| margin: 0 0 6px 0; |
| font-size: 0.95rem; |
| font-weight: 800; |
| color: #312e81; |
| } |
|
|
| .status-text-small { |
| margin: 0; |
| font-size: 0.75rem; |
| line-height: 1.6; |
| color: #64748b; |
| font-weight: 500; |
| padding: 0 5px; |
| } |
|
|
| .bottom-loader { |
| position: absolute; |
| bottom: 0; |
| left: 0; |
| width: 100%; |
| height: 3px; |
| background: rgba(99, 102, 241, 0.1); |
| } |
|
|
| .bottom-loader::after { |
| content: ''; |
| position: absolute; |
| left: 0; |
| top: 0; |
| height: 100%; |
| width: 40%; |
| background: linear-gradient(90deg, transparent, #6366f1, #a855f7, transparent); |
| animation: load-slide 1.5s infinite ease-in-out; |
| } |
|
|
| @keyframes load-slide { |
| 0% { left: -40%; } |
| 100% { left: 100%; } |
| } |
|
|
| @media (max-width: 380px) { |
| .custom-voice-btn-container { |
| grid-column: span 1; |
| } |
| .add-custom-voice-btn { |
| flex-direction: column; |
| text-align: center; |
| gap: 10px; |
| } |
| .custom-voice-text { |
| text-align: center; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="app-container"> |
| <header class="app-header"> |
| <h1>استودیوی ساخت پادکست</h1> |
| <p>گویندگان خود را به پروژه اضافه کنید، سناریو را بنویسید و پادکست خود را تحویل بگیرید.</p> |
| <div id="subscription-status-badge"></div> |
| </header> |
| <main class="main-content"> |
| <form id="podcast-form" onsubmit="return false;"> |
| <div class="form-group"> |
| <label>🎤 تیم گویندگان</label> |
| <div id="project-speakers-container"> |
| <div id="add-speaker-card" class="speaker-display-card"> |
| <div class="plus-icon">+</div> |
| <h3>افزودن گوینده</h3> |
| </div> |
| </div> |
| </div> |
| <div class="form-group"> |
| <div class="form-group-header"> |
| <label>📜 سناریوی گفتگو</label> |
| <button type="button" id="open-ai-modal-btn" class="ai-script-btn"> ✨ساخت پروژه با هوش مصنوعی </button> |
| </div> |
| <div id="podcast-script-container"></div> |
| <button type="button" id="add-turn-btn">+ افزودن نوبت گفتگو</button> |
| </div> |
| <div class="form-group"> |
| <label for="temperature-slider-podcast">🌡️ خلاقیت صدا</label> |
| <div class="slider-container"> |
| <input type="range" id="temperature-slider-podcast" min="0.1" max="1.5" step="0.05" value="0.9"> |
| <span id="temperature-value-podcast" class="temperature-value">0.9</span> |
| </div> |
| </div> |
| <button type="submit" id="generate-btn-podcast" class="generate-btn"> |
| <span class="btn-text">🎙️ ساخت پادکست</span> |
| <div class="spinner"></div> |
| </button> |
| <p id="form-validation-message" class="validation-message"></p> |
| <div id="countdown-timer"> |
| <div class="timer-content"> |
| <svg class="clock-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#856404"><path d="M12 2C6.486 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.514 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8z"></path><path d="M13 7h-2v6l5.25 3.15.75-1.23-4.5-2.67z"></path></svg> |
| <div> |
| <div id="time-left">...</div> |
| <div class="timer-text">زمان باقیمانده تا دریافت اعتبار رایگان</div> |
| </div> |
| </div> |
| </div> |
| <button type="button" id="upgrade-premium-btn"> ⭐️ ارتقا به نسخه کامل و نامحدود </button> |
| </form> |
| <div id="output-section-podcast" class="output-section"> |
| <div id="status-message-podcast" class="status-message">پادکست نهایی در اینجا ظاهر خواهد شد.</div> |
| <div id="loading-animation-wrapper-podcast" class="loading-animation-wrapper"> |
| <div class="orbital-loader"> |
| <div class="orbit"><div class="satellite"></div></div> |
| <div class="orbit"><div class="satellite"></div></div> |
| <div class="orbit"><div class="satellite"></div></div> |
| </div> |
| <p class="loading-text" id="loading-text-podcast">در حال پردازش همزمان...</p> |
| <div id="generation-progress-container"> |
| <div class="progress-grid" id="progress-grid"></div> |
| </div> |
|
|
| <! |
| <div id="custom-voice-warning-container" class="glass-vertical-card"> |
| <div class="icon-circle-wrapper"> |
| <span id="ai-progress-percent">0%</span> |
| </div> |
| <h5 class="status-title">مدل اختصاصی هوشمند</h5> |
| <p class="status-text-small"> |
| شما در حال ساخت پادکست با مدل اختصاصی هستید. فرایند مدل اختصاصی چند مرحلهای بوده و برای ساخت زمان بیشتری نیاز است. لطفاً صبور باشید، ممکن است ۲ تا ۳ دقیقه زمان بیشتری نیاز باشد. |
| </p> |
| <div class="bottom-loader"></div> |
| </div> |
|
|
| </div> |
| <div id="audio-player-content-podcast" class="audio-player-content"> |
| <div class="audio-waveform-container"> |
| <span class="audio-time audio-current-time">0:00</span> |
| <div class="audio-waveform"> |
| <canvas class="audio-waveform-canvas"></canvas> |
| <div class="audio-waveform-dashed-line"></div> |
| </div> |
| <span class="audio-time audio-total-time">0:00</span> |
| </div> |
| <div class="audio-controls-group"> |
| <button type="button" class="audio-skip-btn backward" title="پرش به عقب"> |
| <svg viewBox="0 0 24 24"><path d="M11 16V8l-4 4 4 4zm4-12v16l7-8-7-8z"></path></svg> |
| </button> |
| <button type="button" class="audio-play-pause-btn-large"> |
| <svg viewBox="0 0 24 24" class="play-icon"><path d="M8 5v14l11-7z"></path></svg> |
| <svg viewBox="0 0 24 24" class="pause-icon" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg> |
| </button> |
| <button type="button" class="audio-skip-btn forward" title="پرش به جلو"> |
| <svg viewBox="0 0 24 24"><path d="M13 16V8l4 4-4 4zM9 4v16L2 12l7-8z"></path></svg> |
| </button> |
| </div> |
| <div class="audio-utility-controls"> |
| <button type="button" class="audio-volume-btn" title="قطع/وصل صدا"> |
| <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> |
| <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> |
| </button> |
| <button type="button" class="audio-speed-btn" title="سرعت پخش">1x</button> |
| </div> |
| </div> |
| <button type="button" id="internal-download-btn" class="beautiful-download-btn" style="display:none;"> |
| <svg viewBox="0 0 24 24"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"></path></svg> دانلود با کیفیت اصلی |
| </button> |
| <button type="button" id="clear-history-btn"> |
| <svg viewBox="0 0 24 24"><path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"></path></svg> حذف کامل پروژه و شروع مجدد |
| </button> |
| </div> |
| </main> |
| <audio id="hidden-audio-player" style="display: none;"></audio> |
| <! |
| <input type="file" id="custom-voice-input" accept="audio/*" style="display: none;"> |
| <! |
| <input type="file" id="replace-voice-input" accept="audio/*" style="display: none;"> |
|
|
| <div id="speaker-modal" class="modal-overlay"> |
| <div class="modal-dialog"> |
| <div class="modal-header"> |
| <h2 id="modal-title">انتخاب گوینده برای افزودن به پروژه</h2> |
| <button type="button" class="close-modal-btn">×</button> |
| </div> |
| <div class="modal-body"><div id="speaker-grid"></div></div> |
| </div> |
| </div> |
| |
| <! |
| <div id="custom-voice-info-modal" class="modal-overlay"> |
| <div class="modal-dialog" style="max-width: 500px; text-align: center;"> |
| <div class="modal-header" style="justify-content: center; border: none; padding-bottom: 0;"> |
| <div style="width: 70px; height: 70px; background: var(--accent-primary-glow); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--accent-primary); font-size: 2rem; margin-bottom: 1rem;"> |
| <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg> |
| </div> |
| </div> |
| <h3 style="margin-top:0; font-size: 1.5rem; font-weight: 800; color: var(--text-primary);">ساخت صدای اختصاصی</h3> |
| <div class="modal-body" style="padding-top: 1rem;"> |
| <p style="color: var(--text-secondary); line-height: 1.8; font-size: 0.95rem; text-align: justify;"> |
| با اضافه کردن یک نمونه صدا هوش مصنوعی آلفا پادکست رو بر اساس صدای همون شخصی تولید میکند. یک فایل صوتی با کیفیت بالا بدون نویز در حد صدای استدیو اضافه کنید، بهترین حالت این است که صدای مدل که اضافه میکنید بین ۳ تا ۳۰ ثانیه باشه، هوش مصنوعی آلفا تن صدای شما رو یاد میگیره و پادکست با صدای که اضافه کردید خوانده خواهد شد. این صدا به عنوان مدل در گالری گویندگان اضافه میشود. |
| </p> |
| <div style="background: #fff9db; color: #856404; padding: 12px; border-radius: 12px; font-size: 0.9rem; margin-top: 20px; border: 1px solid #ffeeba; display:flex; align-items:center; gap:10px; text-align: right;"> |
| <span style="font-size:1.2rem">⏳</span> |
| <span>دقت کنید ساخت پادکست با صدای اختصاصی زمان بیشتری نیاز دارد. همچنین کیفیت صدای خروجی مدل شما به کیفیت صدای ورودی شما مرتبط است.</span> |
| </div> |
| </div> |
| <div class="modal-footer" style="border: none; justify-content: center; padding-top: 1.5rem;"> |
| <button id="confirm-custom-voice-btn" class="generate-btn" style="width: 100%; padding: 1rem; font-size: 1.1rem;"> |
| اوکی متوجه شدم اضافه کردن صدا |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| <div id="ai-modal" class="modal-overlay"> |
| <div class="modal-dialog"> |
| <div id="ai-content-wrapper" class="modal-content-wrapper active"> |
| <div class="modal-header"> |
| <h2>ساخت پروژه با هوش مصنوعی</h2> |
| <button type="button" class="close-modal-btn">×</button> |
| </div> |
| <div class="modal-body"> |
| <p>یک موضوع (مثلاً: "تاریخچه پیدایش قهوه رو با سه گوینده بساز") یا یک مقاله کامل را وارد کنید. هوش مصنوعی بهترین تیم گویندگان را انتخاب کرده و سناریوی گفتگو را تولید خواهد کرد.</p> |
| <textarea id="ai-prompt-textarea" placeholder="موضوع یا متن مقاله خود را اینجا وارد کنید..."></textarea> |
| <p id="ai-status-message"></p> |
| </div> |
| <div class="modal-footer"> |
| <button type="button" id="generate-script-ai-btn" class="generate-btn"> 🚀 پروژه را بساز </button> |
| </div> |
| </div> |
| <div id="ai-loading-wrapper" class="modal-loading-wrapper"> |
| <div class="loader-animation"> |
| <div class="core-pulse"></div> |
| <div class="ai-orbit"></div> |
| <div class="ai-orbit"></div> |
| </div> |
| <p id="loading-text-ai">در حال آمادهسازی...</p> |
| </div> |
| </div> |
| </div> |
| <div id="confirm-delete-modal" class="confirm-modal-overlay"> |
| <div class="confirm-modal-dialog"> |
| <h3>تایید عملیات</h3> |
| <p>آیا از حذف کامل سابقه گفتگو و بازنشانی پروژه به حالت اولیه مطمئن هستید؟ این عمل غیرقابل بازگشت است.</p> |
| <div class="confirm-modal-actions"> |
| <button id="cancel-delete-btn" class="cancel-btn">انصراف</button> |
| <button id="confirm-delete-action-btn" class="confirm-btn">بله، حذف کن</button> |
| </div> |
| </div> |
| </div> |
| <! |
| <div id="confirm-delete-speaker-modal" class="confirm-modal-overlay"> |
| <div class="confirm-modal-dialog"> |
| <div style="margin-bottom: 15px; width: 60px; height: 60px; background: #fed7d7; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 15px auto; color: #c53030;"> |
| <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg> |
| </div> |
| <h3>حذف صدای اختصاصی</h3> |
| <p>آیا از حذف این مدل صدای اختصاصی مطمئن هستید؟ این عملیات غیرقابل بازگشت است.</p> |
| <div class="confirm-modal-actions"> |
| <button id="cancel-delete-speaker-btn" class="cancel-btn">انصراف</button> |
| <button id="confirm-delete-speaker-btn" class="confirm-btn">بله، حذف کن</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| <script> |
| document.addEventListener('DOMContentLoaded', () => { |
| const TTS_API_ENDPOINT = '/api/generate'; |
| const AI_CREATE_API_ENDPOINT = '/api/create-full-podcast'; |
| const AI_STATUS_API_ENDPOINT = '/api/podcast-status/'; |
| const PREMIUM_PAGE_ID = '1149636'; |
| const PREMIUM_URL = '#/nav/online/news/getSingle/1149636/eyJpdiI6ImNtTno4RDdUcWIrVmg5azNpUEM4b1E9PSIsInZhbHVlIjoiTGFhUHQ3Q0N6NnRvZWpoYXdMY2lQdGhzZkc0WFhaLzBhdVBpeE4zM2NGR25nRVN5VnFKT0dMZ0x1TDlPbUx4MyIsIm1hYyI6Ijg1MDExMWY2ODQ1YTNlYWEyNWM3NGFlMTcyYzBkNzExMTY4NzM2OTVmM2U5YjVmM2E2NTNhMmFmOThmNGE2ZDYiLCJ0YWciOiIifQ==/20934991'; |
| let userSubscriptionStatus = 'free'; |
| let userFingerprint = null; |
| let countdownInterval = null; |
| let lastAddedSpeakerIndex = -1; |
| let speakerIdToEdit = null; |
| let speakerIdToDelete = null; |
| |
| // |
| let customVoiceTimer = null; |
| |
| function startCustomVoiceProgress() { |
| const container = document.getElementById('custom-voice-warning-container'); |
| const percentElement = document.getElementById('ai-progress-percent'); |
| if (!container || !percentElement) return; |
|
|
| container.style.display = 'flex'; |
| let percentage = 0; |
| percentElement.textContent = '0%'; |
|
|
| if (customVoiceTimer) clearInterval(customVoiceTimer); |
| customVoiceTimer = setInterval(() => { |
| if (percentage < 99) { |
| percentage++; |
| percentElement.textContent = percentage + '%'; |
| } else { |
| clearInterval(customVoiceTimer); |
| } |
| }, 3000); |
| } |
|
|
| function stopCustomVoiceProgress() { |
| const container = document.getElementById('custom-voice-warning-container'); |
| if (container) container.style.display = 'none'; |
| if (customVoiceTimer) clearInterval(customVoiceTimer); |
| } |
| |
| // لیست گویندگان پیشفرض |
| const defaultSpeakers = [ |
| { id: "Charon", name: "شهاب (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/a18705_25IMG-۲۰۲۵۰۷۰۵-۱۱۰۵۴۹.jpg" }, |
| { id: "Zephyr", name: "آوا (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/029605_25IMG-۲۰۲۵۰۷۰۵-۱۱۱۲۵۲.jpg" }, |
| { id: "Achird", name: "نوید (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/697e05_25IMG-۲۰۲۵۰۶۰۹-۰۶۴۶۳۷.jpg" }, |
| { id: "Zubenelgenubi", name: "آرمان (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/a8a705_25IMG-۲۰۲۵۰۷۰۵-۱۱۱۶۲۹.jpg" }, |
| { id: "Vindemiatrix", name: "مهسا (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/d74d05_25IMG-۲۰۲۵۰۷۰۵-۱۱۱۸۳۸.jpg" }, |
| { id: "Rasalgethi", name: "دانا (مرد)", gender: "male", desc: "خبری و آموزنده", imgUrl: "https://uploadkon.ir/uploads/57e425_25IMG-20250925-112825-749.jpg" }, |
| { id: "Sadachbia", name: "سامان (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/580205_25IMG-۲۰۲۵۰۷۰۵-۱۱۳۳۳۰.jpg" }, |
| { id: "Sadaltager", name: "آرش (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/c4db05_25IMG-۲۰۲۵۰۷۰۵-۱۱۳۵۰۰.jpg" }, |
| { id: "Sulafat", name: "شبنم (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/995005_25IMG-۲۰۲۵۰۷۰۵-۱۱۳۶۱۱.jpg" }, |
| { id: "Laomedeia", name: "سحر (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/660705_25IMG-۲۰۲۵۰۷۰۵-۱۱۳۷۵۴.jpg" }, |
| { id: "Achernar", name: "مریم (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/4c2905_25IMG-۲۰۲۵۰۷۰۵-۱۱۴۰۳۶.jpg" }, |
| { id: "Alnilam", name: "بهرام (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/f0c205_25IMG-۲۰۲۵۰۷۰۵-۱۱۴۲۲۰.jpg" }, |
| { id: "Schedar", name: "نیکان (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/d37a05_25IMG-۲۰۲۵۰۷۰۵-۱۱۴۳۲۵.jpg" }, |
| { id: "Gacrux", name: "فرناز (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/495b09_25IMG-20251109-104135-304.jpg" }, |
| { id: "Pulcherrima", name: "سارا (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/acb105_25IMG-۲۰۲۵۰۷۰۵-۱۱۴۷۴۳.jpg" }, |
| { id: "Umbriel", name: "مانی (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/68b505_25IMG-۲۰۲۵۰۷۰۵-۱۱۴۹۱۴.jpg" }, |
| { id: "Algieba", name: "آرتین (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/571005_25IMG-۲۰۲۵۰۷۰۵-۱۱۵۰۳۹.jpg" }, |
| { id: "Despina", name: "دلنواز (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/5d7805_25IMG-۲۰۲۵۰۷۰۵-۱۱۵۲۲۲.jpg" }, |
| { id: "Erinome", name: "روژان (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/aa8805_25IMG-۲۰۲۵۰۷۰۵-۱۱۵۳۴۹.jpg" }, |
| { id: "Algenib", name: "امید (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/a63c05_25IMG-۲۰۲۵۰۷۰۵-۱۱۵۹۲۱.jpg" }, |
| { id: "Orus", name: "بردیا (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/8bc405_25IMG-۲۰۲۵۰۷۰۵-۱۲۱۴۳۳.jpg" }, |
| { id: "Aoede", name: "ترانه (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/9cb405_25IMG-۲۰۲۵۰۷۰۵-۱۲۱۸۵۰.jpg" }, |
| { id: "Callirrhoe", name: "نیکو (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/ee5f05_25IMG-۲۰۲۵۰۷۰۵-۱۲۲۰۴۷.jpg" }, |
| { id: "Autonoe", name: "هستی (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/9b0505_25IMG-۲۰۲۵۰۷۰۵-۱۲۲۲۲۲.jpg" }, |
| { id: "Enceladus", name: "کامیار (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/127805_25IMG-۲۰۲۵۰۷۰۵-۱۲۲۴۱۴.jpg" }, |
| { id: "Iapetus", name: "کیانوش (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/c98b05_25IMG-۲۰۲۵۰۷۰۵-۱۲۲۶۰۵.jpg" }, |
| { id: "Puck", name: "پویا (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/ca3605_25IMG-۲۰۲۵۰۷۰۵-۱۲۲۸۳۹.jpg" }, |
| { id: "Kore", name: "مهتاب (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/b66605_25IMG-۲۰۲۵۰۷۰۵-۱۲۳۰۳۵.jpg" }, |
| { id: "Fenrir", name: "سام (مرد)", gender: "male", imgUrl: "https://uploadkon.ir/uploads/03c005_25IMG-۲۰۲۵۰۷۰۵-۱۲۳۴۱۳.jpg" }, |
| { id: "Leda", name: "لیدا (زن)", gender: "female", imgUrl: "https://uploadkon.ir/uploads/710305_25IMG-۲۰۲۵۰۷۰۵-۱۲۳۷۳۱.jpg" } |
| ]; |
| |
| let speakers = [...defaultSpeakers]; |
| let activeSpeakers = [], masterAudioBlobs = [], currentlyPlayingTurnPlayer = null; |
| let audioPeaks = [], currentPlaybackSpeedIndex = 0; |
| const playbackSpeeds = [1.0, 1.25, 1.5, 0.75]; |
| const mainAudioPlayer = document.getElementById('hidden-audio-player'); |
| const form = document.getElementById('podcast-form'); |
| const scriptContainer = document.getElementById('podcast-script-container'); |
| const addTurnBtn = document.getElementById('add-turn-btn'); |
| const tempSlider = document.getElementById('temperature-slider-podcast'); |
| const tempValueSpan = document.getElementById('temperature-value-podcast'); |
| const generateBtn = document.getElementById('generate-btn-podcast'); |
| const validationMessage = document.getElementById('form-validation-message'); |
| const outputSection = document.getElementById('output-section-podcast'); |
| const statusMessage = document.getElementById('status-message-podcast'); |
| const clearHistoryBtn = document.getElementById('clear-history-btn'); |
| const loadingAnimationWrapper = document.getElementById('loading-animation-wrapper-podcast'); |
| const loadingText = document.getElementById('loading-text-podcast'); |
| const progressGrid = document.getElementById('progress-grid'); |
| const playerContent = document.getElementById('audio-player-content-podcast'); |
| const speakerModal = document.getElementById('speaker-modal'); |
| const modalTitleElement = document.getElementById('modal-title'); |
| const addSpeakerCard = document.getElementById('add-speaker-card'); |
| const projectSpeakersContainer = document.getElementById('project-speakers-container'); |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| const aiModal = document.getElementById('ai-modal'); |
| const openAiModalBtn = document.getElementById('open-ai-modal-btn'); |
| const generateAiBtn = document.getElementById('generate-script-ai-btn'); |
| const aiPromptTextarea = document.getElementById('ai-prompt-textarea'); |
| const aiStatusMessage = document.getElementById('ai-status-message'); |
| const aiContentWrapper = document.getElementById('ai-content-wrapper'); |
| const aiLoadingWrapper = document.getElementById('ai-loading-wrapper'); |
| const aiLoadingText = document.getElementById('loading-text-ai'); |
| const aiStatusMessages = ["در حال تحلیل موضوع...", "انتخاب هوشمند گویندگان...", "نوشتن پیشنویس سناریو...", "بازبینی و نهاییسازی متن...", "آمادهسازی پروژه لطفاً صبور باشید..."]; |
| const internalDownloadBtn = document.getElementById('internal-download-btn'); |
| let pollingInterval = null, statusInterval = null; |
| const confirmDeleteModal = document.getElementById('confirm-delete-modal'); |
| const confirmDeleteBtn = document.getElementById('confirm-delete-action-btn'); |
| const cancelDeleteBtn = document.getElementById('cancel-delete-btn'); |
| const subscriptionBadge = document.getElementById('subscription-status-badge'); |
| const upgradeBtn = document.getElementById('upgrade-premium-btn'); |
| const countdownContainer = document.getElementById('countdown-timer'); |
| const timeLeftDisplay = document.getElementById('time-left'); |
| const confirmDeleteSpeakerModal = document.getElementById('confirm-delete-speaker-modal'); |
|
|
| const dbName = "PodcastStudioAudioDB_V2"; |
| const audioStoreName = "audioChunks"; |
| const refStoreName = "customVoiceRefs"; |
| let dbInstance; |
|
|
| function initDB() { |
| return new Promise((resolve, reject) => { |
| const request = indexedDB.open(dbName, 2); |
| request.onerror = (event) => reject(event); |
| request.onsuccess = (event) => { |
| dbInstance = event.target.result; |
| resolve(dbInstance); |
| }; |
| request.onupgradeneeded = (event) => { |
| const db = event.target.result; |
| if (!db.objectStoreNames.contains(audioStoreName)) { |
| db.createObjectStore(audioStoreName, { keyPath: "index" }); |
| } |
| if (!db.objectStoreNames.contains(refStoreName)) { |
| db.createObjectStore(refStoreName, { keyPath: "id" }); |
| } |
| }; |
| }); |
| } |
|
|
| async function saveAudioToDB(index, blob) { |
| if (!dbInstance) await initDB(); |
| return new Promise((resolve, reject) => { |
| const transaction = dbInstance.transaction([audioStoreName], "readwrite"); |
| const store = transaction.objectStore(audioStoreName); |
| const request = store.put({ index: index, blob: blob }); |
| request.onsuccess = () => resolve(); |
| request.onerror = (e) => reject(e); |
| }); |
| } |
|
|
| async function getAudioFromDB(index) { |
| if (!dbInstance) await initDB(); |
| return new Promise((resolve, reject) => { |
| const transaction = dbInstance.transaction([audioStoreName], "readonly"); |
| const store = transaction.objectStore(audioStoreName); |
| const request = store.get(index); |
| request.onsuccess = (event) => resolve(event.target.result ? event.target.result.blob : null); |
| request.onerror = (e) => reject(e); |
| }); |
| } |
|
|
| async function getAllAudioFromDB() { |
| if (!dbInstance) await initDB(); |
| return new Promise((resolve, reject) => { |
| const transaction = dbInstance.transaction([audioStoreName], "readonly"); |
| const store = transaction.objectStore(audioStoreName); |
| const request = store.getAll(); |
| request.onsuccess = (event) => { |
| const items = event.target.result; |
| items.sort((a, b) => a.index - b.index); |
| resolve(items); |
| }; |
| request.onerror = (e) => reject(e); |
| }); |
| } |
|
|
| async function clearAudioDB() { |
| if (!dbInstance) await initDB(); |
| return new Promise((resolve, reject) => { |
| const transaction = dbInstance.transaction([audioStoreName], "readwrite"); |
| const store = transaction.objectStore(audioStoreName); |
| const request = store.clear(); |
| request.onsuccess = () => resolve(); |
| request.onerror = (e) => reject(e); |
| }); |
| } |
|
|
| async function saveRefAudioToDB(id, blob, name) { |
| if (!dbInstance) await initDB(); |
| return new Promise((resolve, reject) => { |
| const transaction = dbInstance.transaction([refStoreName], "readwrite"); |
| const store = transaction.objectStore(refStoreName); |
| store.put({ id: id, blob: blob, name: name }); |
| transaction.oncomplete = () => resolve(); |
| transaction.onerror = (e) => reject(e); |
| }); |
| } |
|
|
| async function getRefAudioFromDB(id) { |
| if (!dbInstance) await initDB(); |
| return new Promise((resolve, reject) => { |
| const transaction = dbInstance.transaction([refStoreName], "readonly"); |
| const store = transaction.objectStore(refStoreName); |
| const req = store.get(id); |
| req.onsuccess = (e) => resolve(e.target.result); |
| req.onerror = (e) => reject(e); |
| }); |
| } |
| |
| async function deleteRefAudioFromDB(id) { |
| if (!dbInstance) await initDB(); |
| return new Promise((resolve, reject) => { |
| const transaction = dbInstance.transaction([refStoreName], "readwrite"); |
| const store = transaction.objectStore(refStoreName); |
| const req = store.delete(id); |
| req.onsuccess = () => resolve(); |
| req.onerror = (e) => reject(e); |
| }); |
| } |
|
|
| const customVoiceInput = document.getElementById('custom-voice-input'); |
| const replaceVoiceInput = document.getElementById('replace-voice-input'); |
|
|
| const blobToBase64 = (blob) => { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.onloadend = () => resolve(reader.result); |
| reader.onerror = reject; |
| reader.readAsDataURL(blob); |
| }); |
| }; |
|
|
| customVoiceInput.addEventListener('change', async (e) => { |
| const file = e.target.files[0]; |
| if (!file) return; |
| if (file.size > 5 * 1024 * 1024) { alert("حجم فایل صدا باید کمتر از 5 مگابایت باشد."); return; } |
|
|
| const customId = `custom_${Date.now()}`; |
| const customName = `صدای اختصاصی ${speakers.filter(s => s.id.startsWith('custom')).length + 1}`; |
| await saveRefAudioToDB(customId, file, customName); |
| const newSpeaker = { id: customId, name: customName, gender: "custom", isCustom: true, imgUrl: null }; |
| speakers.push(newSpeaker); |
| saveCustomSpeakersMetadata(); |
| addSpeakerToProject(customId); |
| document.getElementById('speaker-modal').classList.remove('visible'); |
| alert("صدای اختصاصی با موفقیت اضافه شد!"); |
| }); |
|
|
| replaceVoiceInput.addEventListener('change', async (e) => { |
| const file = e.target.files[0]; |
| if (!file) return; |
| if (file.size > 5 * 1024 * 1024) { alert("حجم فایل صدا باید کمتر از 5 مگابایت باشد."); return; } |
| if (!speakerIdToEdit) return; |
|
|
| const speaker = speakers.find(s => s.id === speakerIdToEdit); |
| if (speaker) { |
| await saveRefAudioToDB(speakerIdToEdit, file, speaker.name); |
| alert("فایل صوتی با موفقیت جایگزین شد."); |
| speakerIdToEdit = null; |
| } |
| }); |
|
|
| function saveCustomSpeakersMetadata() { |
| const customOnly = speakers.filter(s => s.isCustom); |
| localStorage.setItem('customSpeakersMeta', JSON.stringify(customOnly)); |
| } |
|
|
| async function loadCustomSpeakers() { |
| const meta = localStorage.getItem('customSpeakersMeta'); |
| if (meta) { |
| const customList = JSON.parse(meta); |
| for (const s of customList) { |
| const data = await getRefAudioFromDB(s.id); |
| if (data) { |
| speakers.push(s); |
| } |
| } |
| } |
| } |
| |
| window.playRefPreview = async (id) => { |
| const data = await getRefAudioFromDB(id); |
| if (data && data.blob) { |
| const url = URL.createObjectURL(data.blob); |
| const audio = new Audio(url); |
| audio.play(); |
| } |
| }; |
|
|
| window.renameCustomSpeaker = (id) => { |
| const speaker = speakers.find(s => s.id === id); |
| if (!speaker) return; |
| const newName = prompt("نام جدید را وارد کنید:", speaker.name); |
| if (newName && newName.trim()) { |
| speaker.name = newName.trim(); |
| saveCustomSpeakersMetadata(); |
| getRefAudioFromDB(id).then(data => { if(data) saveRefAudioToDB(id, data.blob, newName.trim()); }); |
| openSpeakerModal(); renderActiveSpeakers(); |
| } |
| }; |
|
|
| window.triggerReplaceAudio = (id) => { speakerIdToEdit = id; replaceVoiceInput.click(); }; |
| |
| window.triggerDeleteCustomSpeaker = (id) => { |
| speakerIdToDelete = id; |
| confirmDeleteSpeakerModal.classList.add('visible'); |
| }; |
|
|
| document.getElementById('confirm-custom-voice-btn').onclick = () => { document.getElementById('custom-voice-info-modal').classList.remove('visible'); document.getElementById('custom-voice-input').click(); }; |
| document.getElementById('custom-voice-info-modal').addEventListener('click', (e) => { if (e.target.id === 'custom-voice-info-modal') e.target.classList.remove('visible'); }); |
|
|
| // |
| async function getBrowserFingerprint() { |
| const components = [navigator.userAgent, navigator.language, screen.width + 'x' + screen.height, new Date().getTimezoneOffset()]; |
| try { |
| const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.textBaseline = "top"; ctx.font = "14px 'Arial'"; ctx.textBaseline = "alphabetic"; ctx.fillStyle = "#f60"; ctx.fillRect(125, 1, 62, 20); ctx.fillStyle = "#069"; ctx.fillText("a1b2c3d4e5f6g7h8i9j0_!@#$%^&*()", 2, 15); components.push(canvas.toDataURL()); |
| } catch (e) { components.push("canvas-error"); } |
| const fingerprintString = components.join('~~~'); let hash = 0; for (let i = 0; i < fingerprintString.length; i++) { const char = fingerprintString.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash |= 0; } return 'fp_' + Math.abs(hash).toString(16); |
| } |
| function startCountdown(resetTimestamp) { |
| if (countdownInterval) clearInterval(countdownInterval); |
| countdownContainer.style.display = 'block'; |
| const updateTimer = () => { |
| const now = Date.now() / 1000; const timeLeft = Math.max(0, resetTimestamp - now); |
| if (timeLeft === 0) { clearInterval(countdownInterval); countdownContainer.style.display = 'none'; updateUIWithServerStatus(); return; } |
| const d = Math.floor(timeLeft / 86400), h = Math.floor((timeLeft % 86400) / 3600), m = Math.floor((timeLeft % 3600) / 60), s = Math.floor(timeLeft % 60); |
| let parts = []; if (d > 0) parts.push(`${d} روز`); if (h > 0) parts.push(`${h} ساعت`); if (m > 0) parts.push(`${m} دقیقه`); if (s > 0) parts.push(`${s} ثانیه`); timeLeftDisplay.textContent = parts.slice(0, 3).join(' و '); |
| }; updateTimer(); countdownInterval = setInterval(updateTimer, 1000); |
| } |
| async function updateUIWithServerStatus() { |
| if (!userFingerprint) return; |
| if (userSubscriptionStatus === 'paid') { generateBtn.disabled = false; generateAiBtn.disabled = false; upgradeBtn.style.display = 'none'; validationMessage.classList.remove('visible'); countdownContainer.style.display = 'none'; if (countdownInterval) clearInterval(countdownInterval); return; } |
| validationMessage.textContent = 'در حال بررسی اعتبار...'; validationMessage.classList.add('visible'); |
| try { |
| const response = await fetch('/api/check-credit', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ fingerprint: userFingerprint }) }); |
| if (!response.ok) throw new Error('Server response was not ok.'); |
| const result = await response.json(); generateAiBtn.disabled = false; |
| if (result.limit_reached) { generateBtn.disabled = true; upgradeBtn.style.display = 'block'; validationMessage.textContent = 'هر هفته در نسخه رایگان امکان ساخت پنج پادکست وجود داره اعتبار ساخت پادکست شما در این هفته تمام شده است.'; startCountdown(result.reset_timestamp); } |
| else { generateBtn.disabled = false; upgradeBtn.style.display = 'none'; validationMessage.textContent = `شما ${result.credits_remaining} اعتبار ساخت پادکست در این هفته دارید.`; countdownContainer.style.display = 'none'; if (countdownInterval) clearInterval(countdownInterval); } |
| } catch (error) { console.error("Credit check failed:", error); validationMessage.textContent = "خطا در بررسی اعتبار. لطفاً صفحه را رفرش کنید."; } |
| } |
| function isUserPaid(userObject) { return userObject && userObject.isLogin && userObject.accessible_pages && (userObject.accessible_pages.includes(PREMIUM_PAGE_ID) || userObject.accessible_pages.includes(parseInt(PREMIUM_PAGE_ID))); } |
| function updateUIForSubscriptionStatus(status) { userSubscriptionStatus = status; if (status === 'paid') { subscriptionBadge.textContent = 'نسخه نامحدود'; subscriptionBadge.className = 'paid-badge'; } else { subscriptionBadge.textContent = 'نسخه رایگان'; subscriptionBadge.className = 'free-badge'; } subscriptionBadge.style.display = 'inline-block'; updateUIWithServerStatus(); } |
| window.addEventListener('message', (event) => { if (event.data && event.data.type === 'USER_STATUS_RESPONSE') { try { const userObject = JSON.parse(event.data.payload); const status = isUserPaid(userObject) ? 'paid' : 'free'; updateUIForSubscriptionStatus(status); } catch (e) { updateUIForSubscriptionStatus('free'); } } }); |
| upgradeBtn.addEventListener('click', () => { parent.postMessage({ type: 'NAVIGATE_TO_PREMIUM', payload: { url: PREMIUM_URL } }, '*'); }); |
|
|
| const showAiModal = () => aiModal.classList.add('visible'); |
| const hideAiModal = () => { aiModal.classList.remove('visible'); resetAiModalView(); }; |
| openAiModalBtn.addEventListener('click', showAiModal); |
| aiModal.querySelector('.close-modal-btn').addEventListener('click', hideAiModal); |
| aiModal.addEventListener('click', e => { if (e.target === aiModal) hideAiModal(); }); |
|
|
| async function mergeAudioBlobs(blobs) { |
| const decodedBuffers=await Promise.all(blobs.filter(b=>b&&b.size>100).map(async blob=>{try{const arrayBuffer=await blob.arrayBuffer();if(audioContext.state==='suspended'){await audioContext.resume()}return await audioContext.decodeAudioData(arrayBuffer)}catch(e){console.error("Error decoding audio chunk:",e);return null}}));const validBuffers=decodedBuffers.filter(buffer=>buffer);if(validBuffers.length===0){return null}const totalLength=validBuffers.reduce((total,buffer)=>total+buffer.length,0);const outputBuffer=audioContext.createBuffer(validBuffers[0].numberOfChannels,totalLength,validBuffers[0].sampleRate);let offset=0;for(const buffer of validBuffers){for(let channel=0;channel<buffer.numberOfChannels;channel++){outputBuffer.getChannelData(channel).set(buffer.getChannelData(channel),offset)}offset+=buffer.length}return outputBuffer |
| } |
| function bufferToWav(buffer) { const numOfChan=buffer.numberOfChannels,length=buffer.length*numOfChan*2+44,bufferArr=new ArrayBuffer(length),view=new DataView(bufferArr),channels=[],sampleRate=buffer.sampleRate;let offset=0,pos=0;const writeString=(s)=>{for(let i=0;i<s.length;i++)view.setUint8(pos++,s.charCodeAt(i))};writeString('RIFF');view.setUint32(pos,36+length,true);pos+=4;writeString('WAVE');writeString('fmt ');view.setUint32(pos,16,true);pos+=4;view.setUint16(pos,1,true);pos+=2;view.setUint16(pos,numOfChan,true);pos+=2;view.setUint32(pos,sampleRate,true);pos+=4;view.setUint32(pos,sampleRate*2*numOfChan,true);pos+=4;view.setUint16(pos,numOfChan*2,true);pos+=2;view.setUint16(pos,16,true);pos+=2;writeString('data');view.setUint32(pos,length-pos-4,true);pos+=4;for(let i=0;i<buffer.numberOfChannels;i++)channels.push(buffer.getChannelData(i));while(pos<length){for(let i=0;i<numOfChan;i++){let s=Math.max(-1,Math.min(1,channels[i][offset]));s=s<0?s*0x8000:s*0x7FFF;view.setInt16(pos,s,true);pos+=2}offset++}return new Blob([view],{type:'audio/wav'})} |
| function resetAiModalView() { if(statusInterval) clearInterval(statusInterval); if(pollingInterval) clearInterval(pollingInterval); pollingInterval = null; statusInterval = null; aiContentWrapper.classList.add('active'); aiLoadingWrapper.classList.remove('active'); updateUIWithServerStatus();} |
| |
| async function attemptAiCreation() { |
| try { |
| const prompt = aiPromptTextarea.value.trim(); |
| const allSpeakerData = speakers.map(s => ({ id: s.id, name: s.name, gender: s.gender })); |
| const response = await fetch(AI_CREATE_API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, available_speakers: allSpeakerData }) }); |
| if (response.status !== 202) { const errorText = await response.text(); throw new Error(`Server status ${response.status}: ${errorText}`); } |
| const responseData = await response.json(); |
| pollForResult(responseData.task_id); |
| } catch (error) { setTimeout(attemptAiCreation, 3000); } |
| } |
| function pollForResult(taskId) { |
| pollingInterval = setInterval(async () => { |
| try { |
| const response = await fetch(`${AI_STATUS_API_ENDPOINT}${taskId}`); |
| if (!response.ok) throw new Error("Polling error"); |
| const result = await response.json(); |
| if (result.status === 'completed') { clearInterval(pollingInterval); clearInterval(statusInterval); populateProjectWithAiData(result.data); } else if (result.status === 'failed') { clearInterval(pollingInterval); setTimeout(attemptAiCreation, 3000); } |
| } catch (error) { console.error('Polling error', error); } |
| }, 3000); |
| } |
| function populateProjectWithAiData(data) { |
| const selectedSpeakerIds = data.selected_speakers; |
| const scriptTurns = data.script; |
| activeSpeakers = []; |
| selectedSpeakerIds.forEach(id => { const speaker = speakers.find(s => s.id === id); if (speaker && !activeSpeakers.some(s => s.id === id)) activeSpeakers.push(speaker); }); |
| renderActiveSpeakers(); |
| scriptContainer.innerHTML = ''; |
| masterAudioBlobs = []; |
| scriptTurns.forEach(turn => addScriptTurn(turn.speaker_id, turn.dialogue)); |
| hideAiModal(); saveState(); |
| } |
| generateAiBtn.addEventListener('click', async () => { |
| if (generateAiBtn.disabled) return; |
| const prompt = aiPromptTextarea.value.trim(); |
| if (!prompt) { aiStatusMessage.style.display = 'block'; aiStatusMessage.textContent='لطفا موضوع را وارد کنید'; setTimeout(()=>aiStatusMessage.style.display='none', 2000); return; } |
| generateAiBtn.disabled = true; aiContentWrapper.classList.remove('active'); aiLoadingWrapper.classList.add('active'); |
| let messageIndex = 0; aiLoadingText.textContent = aiStatusMessages[messageIndex]; |
| statusInterval = setInterval(() => { messageIndex = (messageIndex + 1) % aiStatusMessages.length; aiLoadingText.textContent = aiStatusMessages[messageIndex]; }, 2000); |
| attemptAiCreation(); |
| }); |
|
|
| const formatTime = (s) => { if (isNaN(s) || s < 0) return '0:00'; const m = Math.floor(s / 60); return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`; }; |
| const processAudioForWaveform = (audioBuffer) => { |
| if (!audioBuffer) { audioPeaks = []; return; } |
| const data = audioBuffer.getChannelData(0); const samples = Math.floor(audioBuffer.duration * 40); if (samples === 0) { audioPeaks = []; return; } |
| const peaks = []; const sampleSize = Math.floor(data.length / samples); |
| for (let i = 0; i < samples; i++) { let max = 0; const start = i * sampleSize; for (let j = 0; j < sampleSize; j++) { const val = Math.abs(data[start + j]); if (val > max) max = val; } peaks.push(Math.min(1, Math.max(0, max * 1.5))); } |
| audioPeaks = peaks; |
| }; |
| const drawWaveform = (canvas, progressRatio) => { |
| if (!canvas || !audioPeaks.length) return; |
| const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; ctx.scale(dpr, dpr); |
| const width = canvas.offsetWidth; const height = canvas.offsetHeight; ctx.clearRect(0, 0, width, height); |
| const barWidth = 3; const barGap = 2; const totalBarWidth = barWidth + barGap; const numBars = Math.floor(width / totalBarWidth); const offset = (width - numBars * totalBarWidth) / 2; |
| ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--waveform-color-inactive').trim(); |
| for (let i = 0; i < numBars; i++) { const idx = Math.floor((i / numBars) * audioPeaks.length); const barH = (audioPeaks[idx] || 0) * height; ctx.fillRect(offset + i * totalBarWidth, (height - barH) / 2, barWidth, barH); } |
| const activeFillEnd = progressRatio * width; |
| if (activeFillEnd > 0) { |
| ctx.save(); ctx.beginPath(); ctx.rect(0, 0, activeFillEnd, height); ctx.clip(); ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--waveform-color-active').trim(); |
| for (let i = 0; i < numBars; i++) { const idx = Math.floor((i / numBars) * audioPeaks.length); const barH = (audioPeaks[idx] || 0) * height; ctx.fillRect(offset + i * totalBarWidth, (height - barH) / 2, barWidth, barH); } |
| ctx.restore(); |
| } |
| }; |
| function setupMainPlayerListeners() { |
| const pp = playerContent.querySelector('.audio-play-pause-btn-large'); const sb = playerContent.querySelector('.audio-skip-btn.backward'); const sf = playerContent.querySelector('.audio-skip-btn.forward'); const vb = playerContent.querySelector('.audio-volume-btn'); const sp = playerContent.querySelector('.audio-speed-btn'); |
| pp.onclick = () => mainAudioPlayer.paused ? mainAudioPlayer.play() : mainAudioPlayer.pause(); |
| sb.onclick = () => mainAudioPlayer.currentTime = Math.max(0, mainAudioPlayer.currentTime - 5); |
| sf.onclick = () => mainAudioPlayer.currentTime = Math.min(mainAudioPlayer.duration, mainAudioPlayer.currentTime + 5); |
| vb.onclick = () => { mainAudioPlayer.muted = !mainAudioPlayer.muted; vb.querySelector('.volume-high-icon').style.display = mainAudioPlayer.muted ? 'none' : 'block'; vb.querySelector('.volume-mute-icon').style.display = mainAudioPlayer.muted ? 'block' : 'none'; }; |
| sp.onclick = () => { currentPlaybackSpeedIndex = (currentPlaybackSpeedIndex + 1) % playbackSpeeds.length; mainAudioPlayer.playbackRate = playbackSpeeds[currentPlaybackSpeedIndex]; sp.textContent = `${playbackSpeeds[currentPlaybackSpeedIndex]}x`; }; |
| ['timeupdate', 'play', 'pause', 'ended'].forEach(e => mainAudioPlayer.addEventListener(e, updateMainPlayerUI)); window.addEventListener('resize', updateMainPlayerUI); |
| } |
| const updateMainPlayerUI = () => { |
| const isPlaying = !(mainAudioPlayer.paused || mainAudioPlayer.ended); |
| playerContent.querySelectorAll('.play-icon').forEach(i => i.style.display = isPlaying ? 'none' : 'block'); |
| playerContent.querySelectorAll('.pause-icon').forEach(i => i.style.display = isPlaying ? 'block' : 'none'); |
| playerContent.querySelectorAll('.audio-current-time').forEach(s => s.textContent = formatTime(mainAudioPlayer.currentTime)); |
| playerContent.querySelectorAll('.audio-total-time').forEach(s => s.textContent = isFinite(mainAudioPlayer.duration) ? formatTime(mainAudioPlayer.duration) : '0:00'); |
| const canvas = playerContent.querySelector('.audio-waveform-canvas'); |
| if (canvas) drawWaveform(canvas, isFinite(mainAudioPlayer.duration) && mainAudioPlayer.duration > 0 ? mainAudioPlayer.currentTime / mainAudioPlayer.duration : 0); |
| }; |
|
|
| let saveTimeout; |
| function saveState() { clearTimeout(saveTimeout); saveTimeout = setTimeout(() => { const state = { activeSpeakers: activeSpeakers.map(s => s.id), turns: Array.from(scriptContainer.querySelectorAll('.script-turn')).map(t => ({ speakerId: t.querySelector('.custom-select-container').dataset.selectedId, text: t.querySelector('textarea').value.trim() })), temperature: tempSlider.value }; localStorage.setItem('podcastStudioState', JSON.stringify(state)); }, 500); } |
| async function resetToDefaultState() { localStorage.removeItem('podcastStudioState'); await clearAudioDB(); activeSpeakers = []; scriptContainer.innerHTML = ''; masterAudioBlobs = []; addSpeakerToProject(speakers[0].id); addSpeakerToProject(speakers[1].id); addScriptTurn(speakers[0].id); addScriptTurn(speakers[1].id, '', true); tempSlider.value = 0.9; tempValueSpan.textContent = '0.9'; showUIState('initial', 'پادکست نهایی در اینجا ظاهر خواهد شد.'); } |
| async function loadState() { |
| try { |
| const saved = localStorage.getItem('podcastStudioState'); |
| if (saved) { |
| const state = JSON.parse(saved); |
| activeSpeakers = []; |
| if (state.activeSpeakers) state.activeSpeakers.forEach(sid => { const s = speakers.find(sp => sp.id === sid); if(s) activeSpeakers.push(s); }); else { activeSpeakers.push(speakers[0], speakers[1]); } |
| renderActiveSpeakers(); |
| scriptContainer.innerHTML = ''; |
| if (state.turns) state.turns.forEach(t => addScriptTurn(t.speakerId, t.text, true)); else { addScriptTurn(speakers[0].id, '', true); addScriptTurn(speakers[1].id, '', true); } |
| if (state.temperature) { tempSlider.value = state.temperature; tempValueSpan.textContent = state.temperature; } |
| const chunks = await getAllAudioFromDB(); |
| masterAudioBlobs = []; |
| if (chunks.length > 0) { |
| chunks.forEach(i => { if (i.index < state.turns.length) masterAudioBlobs[i.index] = i.blob; }); |
| if (masterAudioBlobs.some(b => b)) { rebuildFinalAudio(); scriptContainer.querySelectorAll('.script-turn').forEach((d, i) => { if (masterAudioBlobs[i]) setupTurnPlayer(d, i); }); } |
| } |
| clearHistoryBtn.style.display = 'inline-flex'; |
| } else resetToDefaultState(); |
| } catch (e) { resetToDefaultState(); } |
| } |
| function showUIState(state, msg = '') { |
| const spinner = generateBtn.querySelector(".spinner"); const txt = generateBtn.querySelector(".btn-text"); |
| if (state !== 'loading') updateUIWithServerStatus(); |
| spinner.style.display = state === 'loading' ? 'inline-block' : 'none'; txt.textContent = state === 'loading' ? 'در حال ساخت...' : '🎙️ ساخت پادکست'; |
| outputSection.classList.toggle('has-content', state === 'result'); playerContent.style.display = 'none'; statusMessage.style.display = 'none'; loadingAnimationWrapper.style.display = 'none'; clearHistoryBtn.style.display = 'none'; internalDownloadBtn.style.display = 'none'; |
| |
| // مخفی کردن هشدار مدل اختصاصی در صورت عدم بارگذاری |
| if (state !== 'loading') { |
| stopCustomVoiceProgress(); |
| } |
|
|
| if (state === 'error') { statusMessage.style.display = 'block'; statusMessage.classList.add('error'); statusMessage.textContent = msg; if (masterAudioBlobs.length > 0) clearHistoryBtn.style.display = 'flex'; } |
| else if (state === 'loading') { loadingAnimationWrapper.style.display = 'flex'; loadingText.textContent = msg; } |
| else if (state === 'result' && masterAudioBlobs.length > 0) { playerContent.style.display = 'flex'; internalDownloadBtn.style.display = 'flex'; clearHistoryBtn.style.display = 'flex'; } |
| } |
|
|
| function renderActiveSpeakers() { |
| const container = document.getElementById('project-speakers-container'); |
| const addCard = document.getElementById('add-speaker-card'); |
| document.querySelectorAll('.speaker-display-card:not(#add-speaker-card)').forEach(c => c.remove()); |
| activeSpeakers.forEach(speaker => { |
| const card = document.createElement('div'); card.className = 'speaker-display-card'; card.dataset.id = speaker.id; |
| let visualHTML = ''; |
| if (speaker.isCustom) { |
| visualHTML = `<div class="custom-voice-avatar"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg></div>`; |
| } else { |
| visualHTML = `<img src="${speaker.imgUrl}" alt="${speaker.name}">`; |
| } |
| card.innerHTML = `<button type="button" class="remove-speaker-btn" title="حذف">×</button>${visualHTML}<h3>${speaker.name}</h3>`; |
| container.insertBefore(card, addCard); |
| card.querySelector('.remove-speaker-btn').addEventListener('click', (e) => { e.stopPropagation(); removeSpeakerFromProject(speaker.id); }); |
| card.addEventListener('click', () => { speakerToReplaceId = speaker.id; openSpeakerModal(); }); |
| }); |
| updateAllTurnSelects(); saveState(); |
| } |
|
|
| function openSpeakerModal() { |
| const grid = document.getElementById('speaker-grid'); grid.innerHTML = ''; |
| const activeIds = activeSpeakers.map(s => s.id); |
| document.getElementById('modal-title').textContent = speakerToReplaceId ? "جایگزینی گوینده" : "انتخاب گوینده"; |
| |
| speakers.forEach(speaker => { |
| const card = document.createElement('div'); card.className = 'speaker-card custom-speaker-card'; |
| if (activeIds.includes(speaker.id) && speaker.id !== speakerToReplaceId) card.classList.add('disabled'); |
| let visualContent; |
| |
| let menuHtml = ''; |
| if (speaker.isCustom) { |
| menuHtml = `<button class="card-menu-btn" onclick="event.stopPropagation(); this.nextElementSibling.classList.toggle('active');">⋮</button><div class="card-menu-dropdown"><div class="card-menu-item" onclick="event.stopPropagation(); renameCustomSpeaker('${speaker.id}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>تغییر نام</div><div class="card-menu-item" onclick="event.stopPropagation(); triggerReplaceAudio('${speaker.id}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>تغییر فایل صدا</div><div class="card-menu-item delete-item" onclick="event.stopPropagation(); triggerDeleteCustomSpeaker('${speaker.id}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>حذف</div></div>`; |
| visualContent = `<div class="speaker-visual"><div class="custom-voice-avatar"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg></div><div class="audio-preview-mini" onclick="event.stopPropagation(); playRefPreview('${speaker.id}')"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></div></div>`; |
| } else { |
| visualContent = `<div class="speaker-visual"><img src="${speaker.imgUrl}" alt="${speaker.name}"></div>`; |
| } |
| |
| card.innerHTML = `${menuHtml}${visualContent}<div class="speaker-name">${speaker.name}</div>`; |
| |
| if (!card.classList.contains('disabled')) { |
| card.addEventListener('click', () => { |
| if (speakerToReplaceId) { |
| const idx = activeSpeakers.findIndex(s => s.id === speakerToReplaceId); |
| if (idx > -1) { |
| activeSpeakers[idx] = speakers.find(s => s.id === speaker.id); |
| |
| // >>> بخش جدید: بروزرسانی خودکار نوبتهای اسکریپت <<< |
| document.querySelectorAll('.script-turn .custom-select-container').forEach(select => { |
| if (select.dataset.selectedId === speakerToReplaceId) { |
| select.dataset.selectedId = speaker.id; |
| } |
| }); |
| } |
| lastAddedSpeakerIndex = -1; |
| speakerToReplaceId = null; |
| } else { |
| addSpeakerToProject(speaker.id); |
| } |
| renderActiveSpeakers(); |
| document.getElementById('speaker-modal').classList.remove('visible'); |
| saveState(); |
| }); |
| } |
| grid.appendChild(card); |
| }); |
|
|
| const customBtn = document.createElement('div'); |
| customBtn.className = 'custom-voice-btn-container'; |
| customBtn.innerHTML = `<div class="add-custom-voice-btn" onclick="document.getElementById('custom-voice-info-modal').classList.add('visible')"><div class="custom-voice-icon-wrapper"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line><line x1="16" y1="6" x2="22" y2="6"></line><line x1="19" y1="3" x2="19" y2="9"></line></svg></div><div class="custom-voice-text"><h4>ساخت صدای اختصاصی</h4><p>با آپلود یک نمونه صدا، پادکست را با صدای خودتان یا صدای دلخواه بسازید.</p></div></div>`; |
| grid.appendChild(customBtn); |
| document.getElementById('speaker-modal').classList.add('visible'); |
| } |
|
|
| document.addEventListener('click', (e) => { |
| if(!e.target.closest('.card-menu-btn')) { |
| document.querySelectorAll('.card-menu-dropdown.active').forEach(d => d.classList.remove('active')); |
| } |
| const trig = e.target.closest('.custom-select-trigger'); const opt = e.target.closest('.custom-select-option'); |
| document.querySelectorAll('.custom-select-container.open').forEach(c => { if (!c.contains(e.target)) c.classList.remove('open'); }); |
| if (trig) trig.closest('.custom-select-container').classList.toggle('open'); |
| else if (opt) { const c = opt.closest('.custom-select-container'); c.dataset.selectedId = opt.dataset.id; updateSingleTurnSelect(c, opt.dataset.id); c.classList.remove('open'); saveState(); } |
| }); |
|
|
| function addSpeakerToProject(speakerId) { |
| if (!activeSpeakers.some(s => s.id === speakerId)) { |
| const speaker = speakers.find(s => s.id === speakerId); |
| if (speaker) { activeSpeakers.push(speaker); renderActiveSpeakers(); lastAddedSpeakerIndex = -1; } |
| } |
| } |
| function removeSpeakerFromProject(speakerId) { |
| activeSpeakers = activeSpeakers.filter(s => s.id !== speakerId); |
| renderActiveSpeakers(); |
| document.querySelectorAll('.script-turn').forEach(turnDiv => { |
| const select = turnDiv.querySelector('.custom-select-container'); |
| if (select.dataset.selectedId === speakerId) { select.dataset.selectedId = activeSpeakers.length > 0 ? activeSpeakers[0].id : ''; updateSingleTurnSelect(select); } |
| }); |
| lastAddedSpeakerIndex = -1; |
| } |
| function updateAllTurnSelects() { document.querySelectorAll('.custom-select-container').forEach(c => updateSingleTurnSelect(c, c.dataset.selectedId)); } |
| function updateSingleTurnSelect(container, preSelectedId = null) { |
| const trigger = container.querySelector('.custom-select-trigger'); |
| const optionsDiv = container.querySelector('.custom-select-options'); |
| optionsDiv.innerHTML = ''; |
| if (activeSpeakers.length === 0) { trigger.innerHTML = `<span>گویندهای انتخاب نشده</span><svg class="arrow" viewBox="0 0 24 24"><path fill="currentColor" d="M7,10L12,15L17,10H7Z"></path></svg>`; container.dataset.selectedId = ''; return; } |
| let selectedSpeaker = activeSpeakers.find(s => s.id === preSelectedId); |
| if (!selectedSpeaker) { selectedSpeaker = activeSpeakers[0]; preSelectedId = selectedSpeaker.id; } |
| const getIcon = (s) => s.isCustom ? `<div style="width:40px;height:40px;border-radius:10px;background:#764ba2;display:flex;align-items:center;justify-content:center;color:white"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg></div>` : `<img src="${s.imgUrl}" style="width:40px;height:40px;border-radius:10px;object-fit:cover;">`; |
| activeSpeakers.forEach(s => { |
| const opt = document.createElement('div'); opt.className = 'custom-select-option'; opt.dataset.id = s.id; |
| opt.innerHTML = `${getIcon(s)}<span>${s.name}</span>`; |
| optionsDiv.appendChild(opt); |
| }); |
| trigger.innerHTML = `${getIcon(selectedSpeaker)}<span>${selectedSpeaker.name}</span><svg class="arrow" viewBox="0 0 24 24" style="margin-right:auto;width:20px"><path fill="currentColor" d="M7,10L12,15L17,10H7Z"></path></svg>`; |
| container.dataset.selectedId = preSelectedId; |
| } |
|
|
| function addScriptTurn(initialSpeakerId = null, initialText = '', fromLoad = false) { |
| if (activeSpeakers.length === 0) return; |
| const turnDiv = document.createElement('div'); turnDiv.className = 'script-turn'; |
| turnDiv.innerHTML = `<div class="turn-speaker-selector"><div class="custom-select-container" data-selected-id=""><div class="custom-select-trigger"></div><div class="custom-select-options"></div></div></div><div class="turn-content"><textarea placeholder="متن گفتگو..."></textarea><div class="turn-player-container"><button type="button" class="turn-play-btn"><svg viewBox="0 0 24 24" class="play-icon"><path d="M8 5v14l11-7z"></path></svg><svg viewBox="0 0 24 24" class="pause-icon"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"></path></svg></button><audio class="turn-audio" style="display:none;"></audio><div class="loading-state-wrapper" style="display:none;"><div class="spinner"></div><span class="loading-text">در حال جایگزین صدا</span></div><button type="button" class="turn-retry-btn"><svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"></path></svg>جایگزین قطعه</button><span class="replace-success-message" style="display:none;">فایل جدید جایگزین شد</span><span class="main-update-message" style="display:none;">فایل اصلی بروز شد</span></div></div><button type="button" class="remove-turn-btn" title="حذف">×</button>`; |
| const select = turnDiv.querySelector('.custom-select-container'); const ta = turnDiv.querySelector('textarea'); |
| ta.value = initialText; let sid = initialSpeakerId || activeSpeakers[0]?.id; if (!activeSpeakers.some(s => s.id === sid)) sid = activeSpeakers[0]?.id; |
| updateSingleTurnSelect(select, sid); |
| ta.addEventListener('input', saveState); select.addEventListener('click', saveState); |
| turnDiv.querySelector('.remove-turn-btn').addEventListener('click', async () => { const idx = Array.from(scriptContainer.children).indexOf(turnDiv); if (idx > -1) masterAudioBlobs.splice(idx, 1); turnDiv.remove(); await clearAudioDB(); masterAudioBlobs.forEach(async (b, i) => { if (b) await saveAudioToDB(i, b); }); if(masterAudioBlobs.length>0) rebuildFinalAudio(); else showUIState('initial'); lastAddedSpeakerIndex=-1; saveState(); }); |
| scriptContainer.appendChild(turnDiv); |
| if (!fromLoad) lastAddedSpeakerIndex = activeSpeakers.findIndex(s => s.id === sid); |
| } |
|
|
| // |
| async function handleRetry(turnDiv, turnIndex) { |
| const pc = turnDiv.querySelector('.turn-player-container'); |
| const rb = pc.querySelector('.turn-retry-btn'); |
| const lsw = pc.querySelector('.loading-state-wrapper'); |
| const lt = pc.querySelector('.loading-text'); |
| const succMsg = pc.querySelector('.replace-success-message'); |
| const mainMsg = pc.querySelector('.main-update-message'); |
| const ta = turnDiv.querySelector('textarea'); |
| const sid = turnDiv.querySelector('.custom-select-container').dataset.selectedId; |
|
|
| if (!ta.value.trim() || !sid) return alert("متن یا گوینده نامعتبر است."); |
|
|
| rb.style.display = 'none'; |
| lsw.style.display = 'flex'; |
| succMsg.style.display = 'none'; |
| mainMsg.style.display = 'none'; |
|
|
| // منطق پیام سفارشی زماندار |
| const spk = speakers.find(s => s.id === sid); |
| const isCustom = spk && spk.isCustom; |
| let customMsgTimer; |
|
|
| if(isCustom) { |
| lt.textContent = "قطعه اختصاصی زمان بیشتری نیاز داره برای تغییر لطفاً صبور باشید"; |
| lt.style.fontSize = "0.7em"; |
| customMsgTimer = setTimeout(() => { |
| lt.textContent = "در حال جایگزین صدا..."; |
| lt.style.fontSize = ""; |
| }, 7000); |
| } else { |
| lt.textContent = "در حال جایگزین صدا..."; |
| lt.style.fontSize = ""; |
| } |
|
|
| try { |
| let payload = { text: ta.value.trim(), speaker: sid, temperature: parseFloat(tempSlider.value) }; |
| if (isCustom) { const rd = await getRefAudioFromDB(spk.id); if (rd && rd.blob) payload.ref_audio_base64 = await blobToBase64(rd.blob); } |
| |
| const res = await fetch(TTS_API_ENDPOINT, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); |
| if (!res.ok) throw new Error(await res.text()); |
| const blob = await res.blob(); |
| |
| clearTimeout(customMsgTimer); |
|
|
| // بروزرسانی فایلها |
| masterAudioBlobs[turnIndex] = blob; |
| await saveAudioToDB(turnIndex, blob); |
| pc.querySelector('.turn-audio').src = URL.createObjectURL(blob); |
| |
| // دنباله انیمیشن موفقیت (طبق درخواست: فایل جدید -> فایل اصلی -> دکمه) |
| lsw.style.display = 'none'; |
| succMsg.style.display = 'flex'; |
| succMsg.style.animation = 'none'; |
| void succMsg.offsetWidth; |
| succMsg.style.animation = 'fadeInOut 2.5s forwards'; |
|
|
| setTimeout(async () => { |
| succMsg.style.display = 'none'; |
| mainMsg.style.display = 'flex'; |
| mainMsg.style.animation = 'none'; |
| void mainMsg.offsetWidth; |
| mainMsg.style.animation = 'fadeInOut 2.5s forwards'; |
|
|
| await rebuildFinalAudio(); |
|
|
| setTimeout(() => { |
| mainMsg.style.display = 'none'; |
| rb.style.display = 'flex'; |
| }, 2500); |
| }, 2500); |
|
|
| } catch (e) { |
| clearTimeout(customMsgTimer); |
| lsw.style.display = 'none'; |
| rb.style.display = 'flex'; |
| alert("تلاش مجدد شکست خورد."); |
| } |
| } |
|
|
| async function rebuildFinalAudio() { |
| showUIState('loading', 'میکس مجدد...'); try { const m = await mergeAudioBlobs(masterAudioBlobs); if (!m) { showUIState('initial'); return; } mainAudioPlayer.src = URL.createObjectURL(bufferToWav(m)); mainAudioPlayer.onloadedmetadata = () => { processAudioForWaveform(m); showUIState('result'); updateMainPlayerUI(); }; } catch (e) { showUIState('error', e.message); } |
| } |
| |
| async function proceedWithGeneration(shouldDeductCredit) { |
| // ... (کدهای قبلی) |
| const scriptData = Array.from(scriptContainer.querySelectorAll('.script-turn')).map((t, index) => ({ |
| element: t, index: index, speakerId: t.querySelector('.custom-select-container').dataset.selectedId, text: t.querySelector('textarea').value.trim() |
| })).filter(d => d.text.length > 0 && d.speakerId); |
| |
| if (activeSpeakers.length === 0) { alert('لطفا حداقل یک گوینده اضافه کنید.'); return; } |
| if (scriptData.length === 0) { generateBtn.classList.add('shake-it'); validationMessage.textContent = 'لطفاً سناریو را کامل کنید.'; validationMessage.classList.add('visible'); setTimeout(() => { generateBtn.classList.remove('shake-it'); validationMessage.classList.remove('visible'); }, 4000); return; } |
| |
| generateBtn.disabled = true; progressGrid.innerHTML = ''; |
| scriptData.forEach((item, i) => { const ind = document.createElement('div'); ind.className = 'progress-item pending'; ind.id = `progress-segment-${item.index}`; ind.textContent = i + 1; progressGrid.appendChild(ind); }); |
| |
| showUIState('loading', `در حال پردازش (0 از ${scriptData.length} تکمیل شده)`); |
| |
| // |
| const hasCustomVoice = scriptData.some(item => { |
| const spk = speakers.find(s => s.id === item.speakerId); |
| return spk && spk.isCustom; |
| }); |
|
|
| if (hasCustomVoice) { |
| startCustomVoiceProgress(); |
| } else { |
| stopCustomVoiceProgress(); |
| } |
| // |
|
|
| masterAudioBlobs = new Array(scriptData.length).fill(null); await clearAudioDB(); |
|
|
| try { |
| let completedCount = 0; |
| const promises = scriptData.map(async segment => { |
| const spk = speakers.find(s => s.id === segment.speakerId); |
| let payload = { text: segment.text, speaker: segment.speakerId, temperature: parseFloat(tempSlider.value) }; |
| if (spk && spk.isCustom) { |
| const refData = await getRefAudioFromDB(spk.id); |
| if (refData && refData.blob) payload.ref_audio_base64 = await blobToBase64(refData.blob); |
| } |
| |
| return fetch(TTS_API_ENDPOINT, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }) |
| .then(async res => { |
| if (res.ok) { |
| const blob = await res.blob(); |
| const ind = document.getElementById(`progress-segment-${segment.index}`); if(ind){ ind.classList.remove('pending'); ind.classList.add('done'); ind.textContent = '✓'; } |
| completedCount++; loadingText.textContent = `در حال پردازش (${completedCount} از ${scriptData.length} تکمیل شده)`; |
| return { index: segment.index, blob: blob }; |
| } else { |
| const ind = document.getElementById(`progress-segment-${segment.index}`); if(ind){ ind.classList.remove('pending'); ind.classList.add('error'); ind.textContent = '!'; } |
| throw new Error(await res.text()); |
| } |
| }); |
| }); |
|
|
| const results = await Promise.all(promises); |
| results.forEach(async r => { masterAudioBlobs[r.index] = r.blob; await saveAudioToDB(r.index, r.blob); }); |
| loadingText.textContent = 'میکس نهایی صدا...'; |
| const merged = await mergeAudioBlobs(masterAudioBlobs); |
| if (!merged) throw new Error("میکس صدا شکست خورد."); |
| mainAudioPlayer.src = URL.createObjectURL(bufferToWav(merged)); |
| mainAudioPlayer.onloadedmetadata = () => { processAudioForWaveform(merged); showUIState('result'); }; |
| scriptData.forEach(i => setupTurnPlayer(i.element, i.index)); |
| |
| if (shouldDeductCredit) { fetch('/api/use-credit', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ fingerprint: userFingerprint }) }); } |
|
|
| } catch (e) { console.error(e); showUIState('error', `عملیات شکست خورد: ${e.message}`); } |
| } |
|
|
| function setupTurnPlayer(div, idx) { |
| const pc = div.querySelector('.turn-player-container'); const pb = pc.querySelector('.turn-play-btn'); const ta = pc.querySelector('.turn-audio'); const rb = pc.querySelector('.turn-retry-btn'); |
| if (masterAudioBlobs[idx]) { if(ta.src) URL.revokeObjectURL(ta.src); ta.src = URL.createObjectURL(masterAudioBlobs[idx]); } else { pc.classList.remove('visible'); return; } |
| pb.onclick = () => { if (currentlyPlayingTurnPlayer && currentlyPlayingTurnPlayer !== ta) { currentlyPlayingTurnPlayer.pause(); currentlyPlayingTurnPlayer.closest('.turn-player-container').classList.remove('playing'); } if (ta.paused) { ta.play(); currentlyPlayingTurnPlayer = ta; } else ta.pause(); }; |
| ta.onplay = () => pc.classList.add('playing'); ta.onpause = () => pc.classList.remove('playing'); ta.onended = () => { pc.classList.remove('playing'); currentlyPlayingTurnPlayer = null; }; |
| rb.onclick = () => handleRetry(div, idx); pc.classList.add('visible'); |
| } |
| |
| // |
| document.getElementById('confirm-delete-speaker-btn').addEventListener('click', async () => { |
| if (speakerIdToDelete) { |
| // حذف از آرایه speakers |
| speakers = speakers.filter(s => s.id !== speakerIdToDelete); |
| // ذخیره مجدد متادیتا در localStorage |
| saveCustomSpeakersMetadata(); |
| // حذف از IndexedDB |
| await deleteRefAudioFromDB(speakerIdToDelete); |
| // اگر این گوینده در پروژه فعال بود، حذفش کن |
| if (activeSpeakers.some(s => s.id === speakerIdToDelete)) { |
| removeSpeakerFromProject(speakerIdToDelete); |
| } |
| |
| // رفرش کردن گرید |
| openSpeakerModal(); |
| |
| // بستن مودال |
| confirmDeleteSpeakerModal.classList.remove('visible'); |
| speakerIdToDelete = null; |
| } |
| }); |
|
|
| document.getElementById('cancel-delete-speaker-btn').addEventListener('click', () => { |
| confirmDeleteSpeakerModal.classList.remove('visible'); |
| speakerIdToDelete = null; |
| }); |
|
|
| form.addEventListener('submit', async () => { if (generateBtn.disabled) return; if (userSubscriptionStatus === 'paid') { await proceedWithGeneration(false); } else { try { const r = await fetch('/api/check-credit', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ fingerprint: userFingerprint }) }); const res = await r.json(); if (res.limit_reached) { generateBtn.disabled = true; upgradeBtn.style.display = 'block'; validationMessage.textContent = 'اعتبار تمام شد.'; startCountdown(res.reset_timestamp); return; } await proceedWithGeneration(true); } catch (e) { showUIState('error', e.message); } } }); |
| clearHistoryBtn.onclick = () => confirmDeleteModal.classList.add('visible'); confirmDeleteBtn.onclick = () => { resetToDefaultState(); confirmDeleteModal.classList.remove('visible'); }; cancelDeleteBtn.onclick = () => confirmDeleteModal.classList.remove('visible'); |
| addSpeakerCard.onclick = () => { speakerToReplaceId = null; openSpeakerModal(); }; |
| document.querySelector('.close-modal-btn').onclick = () => document.getElementById('speaker-modal').classList.remove('visible'); |
| internalDownloadBtn.onclick = async () => { if(!mainAudioPlayer.src)return; try{ const b = await (await fetch(mainAudioPlayer.src)).blob(); window.parent.postMessage({ type: 'PROCESS_AND_DOWNLOAD_AUDIO', blob: b }, '*'); } catch(e){ alert("Download failed"); }}; |
| addTurnBtn.onclick = () => { if (activeSpeakers.length === 0) return alert('گوینده اضافه کنید'); lastAddedSpeakerIndex = (lastAddedSpeakerIndex + 1) % activeSpeakers.length; addScriptTurn(activeSpeakers[lastAddedSpeakerIndex].id); }; |
| tempSlider.oninput = () => { tempValueSpan.textContent = tempSlider.value; saveState(); }; |
|
|
| (async () => { |
| await initDB(); await loadCustomSpeakers(); await loadState(); initializeApp(); |
| })(); |
| async function initializeApp() { userFingerprint = await getBrowserFingerprint(); parent.postMessage({ type: 'REQUEST_USER_STATUS' }, '*'); setupMainPlayerListeners(); } |
| }); |
| </script> |
| </body> |
| </html> |