From 0e44e44ecb760e12e79f61bf83b5c2bf5668471f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 23 Jul 2026 08:32:23 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20b=E1=BB=95=20sung=20MIDI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/projects.py | 152 +- app/core/render_engine.py | 229 + app/models/project_schema.json | 142 + app/static/js/app.jsx | 1186 +- app/static/js/app.precompiled.js | 12419 +--------------- app/static/js/services/storage.js | 26 +- app/storage/sonicforge.db | Bin 45056 -> 45056 bytes app/tasks/worker.py | 24 + md/30_DAW_ARCHITECT.md | 760 + md/30_MIDI_SESSION.md | 361 - md/31_MIDIKEY.md | 426 + md/32_SF_VSTi.md | 237 + md/{31_TAB_CLONE.md => TAB_CLONE_template.md} | 0 requirements.txt | 2 + tests/test_render_engine.py | 20 + 15 files changed, 3304 insertions(+), 12680 deletions(-) create mode 100644 app/core/render_engine.py create mode 100644 app/models/project_schema.json create mode 100644 md/30_DAW_ARCHITECT.md delete mode 100644 md/30_MIDI_SESSION.md create mode 100644 md/31_MIDIKEY.md create mode 100644 md/32_SF_VSTi.md rename md/{31_TAB_CLONE.md => TAB_CLONE_template.md} (100%) create mode 100644 tests/test_render_engine.py diff --git a/app/api/v1/projects.py b/app/api/v1/projects.py index 03ee03f..9e0030b 100644 --- a/app/api/v1/projects.py +++ b/app/api/v1/projects.py @@ -1,14 +1,119 @@ import time import json import uuid +import os from fastapi import APIRouter, HTTPException, Depends, Header from pydantic import BaseModel from typing import Optional, Any, Dict +from jsonschema import validate, ValidationError from app.models.user import get_db_connection from app.api.v1.auth import get_current_user, decode_token router = APIRouter() +SCHEMA_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "models", "project_schema.json") + +def upgrade_project_json_if_needed(project_data: dict) -> dict: + if "main_session" in project_data: + return project_data + + tracks = project_data.get("tracks", []) + upgraded_tracks = [] + for t in tracks: + track_id = str(t.get("id", "")) + track_name = t.get("name", "Track") + vol = t.get("volumeDb", 0.0) + pan = t.get("pan", 0.0) + muted = t.get("muted", False) + solo = t.get("solo", False) + + items = [] + for c in t.get("clips", []): + items.append({ + "id": c.get("id"), + "name": c.get("name", "Audio Clip"), + "type": "AUDIO_ITEM", + "start_bar": c.get("startTime", 0.0) / 4.0, + "duration_bars": 4.0, + "clip_start_offset_bars": 0.0, + "source_data": { + "audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "", + "sample_rate": 44100, + "channels": 2, + "gain": 1.0 + } + }) + for m in t.get("midiItems", []): + items.append({ + "id": m.get("id"), + "name": m.get("name", "MIDI Item"), + "type": "MIDI_ITEM", + "start_bar": m.get("startTime", 0.0) / 4.0, + "duration_bars": m.get("duration", 4.0), + "clip_start_offset_bars": 0.0, + "source_data": { + "total_buffer_bars": m.get("duration", 8.0), + "notes": m.get("notes", []) + } + }) + + upgraded_tracks.append({ + "id": track_id, + "name": track_name, + "type": "MIDI" if t.get("midiItems") else "AUDIO", + "volume_db": vol, + "pan": pan, + "mute": muted, + "solo": solo, + "fx_chain": [], + "synth_engine": { + "plugin_id": "synth", + "preset_id": "default", + "parameters": {} + }, + "items": items + }) + + return { + "project_id": project_data.get("id", "temp_project"), + "metadata": { + "title": project_data.get("name", "Dự án mới"), + "bpm": 120.0, + "time_signature_numerator": 4, + "time_signature_denominator": 4, + "sample_rate": 44100 + }, + "main_session": { + "id": "main", + "name": "MAIN SESSION", + "is_root": True, + "length_bars": 16.0, + "auto_compute_length": True, + "tracks": upgraded_tracks + }, + "section_store": {} + } + +def validate_project_data(data_json: str) -> str: + try: + data = json.loads(data_json) + if "main_session" not in data: + data = upgrade_project_json_if_needed(data) + data_json = json.dumps(data) + + if os.path.exists(SCHEMA_PATH): + with open(SCHEMA_PATH, "r") as f: + schema = json.load(f) + validate(instance=data, schema=schema) + return data_json + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Cấu trúc JSON không hợp lệ: {str(e)}") + except ValidationError as e: + path = " -> ".join(str(p) for p in e.path) + raise HTTPException(status_code=400, detail=f"Lỗi xác thực project schema tại [{path}]: {e.message}") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Lỗi xác thực dự án: {str(e)}") + class SaveProjectRequest(BaseModel): name: str data_json: str @@ -24,11 +129,12 @@ def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[d @router.post("/temp") async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[dict] = Depends(get_optional_user)): + validated_data_json = validate_project_data(req.data_json) user_id = current_user["user_id"] if current_user else "anonymous" conn = get_db_connection() cursor = conn.cursor() - size_bytes = len(req.data_json.encode("utf-8")) + size_bytes = len(validated_data_json.encode("utf-8")) now = time.time() temp_id = f"temp_{user_id}" @@ -36,7 +142,7 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[ INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?, ?, 'Dự án tạm chưa lưu', ?, 1, ?, ?) ON CONFLICT(id) DO UPDATE SET data_json = excluded.data_json, size_bytes = excluded.size_bytes, updated_at = excluded.updated_at - """, (temp_id, user_id, req.data_json, size_bytes, now)) + """, (temp_id, user_id, validated_data_json, size_bytes, now)) conn.commit() conn.close() @@ -64,6 +170,7 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u @router.post("/cloud") async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)): + validated_data_json = validate_project_data(req.data_json) user_id = current_user["user_id"] conn = get_db_connection() cursor = conn.cursor() @@ -76,7 +183,7 @@ async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depen used_row = cursor.fetchone() used_bytes = used_row["total_used"] if used_row and used_row["total_used"] else 0 - new_size_bytes = len(req.data_json.encode("utf-8")) + new_size_bytes = len(validated_data_json.encode("utf-8")) max_bytes = storage_limit_mb * 1024 * 1024 if used_bytes + new_size_bytes > max_bytes: @@ -92,7 +199,7 @@ async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depen cursor.execute(""" INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?) - """, (project_id, user_id, req.name, req.data_json, new_size_bytes, now)) + """, (project_id, user_id, req.name, validated_data_json, new_size_bytes, now)) temp_id = f"temp_{user_id}" cursor.execute("DELETE FROM projects WHERE id = ? AND is_temp = 1", (temp_id,)) @@ -157,6 +264,7 @@ async def delete_cloud_project(project_id: str, current_user: dict = Depends(get @router.put("/cloud/{project_id}") async def update_cloud_project(project_id: str, req: SaveProjectRequest, current_user: dict = Depends(get_current_user)): + validated_data_json = validate_project_data(req.data_json) user_id = current_user["user_id"] conn = get_db_connection() cursor = conn.cursor() @@ -167,15 +275,47 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current conn.close() raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật") - new_size_bytes = len(req.data_json.encode("utf-8")) + new_size_bytes = len(validated_data_json.encode("utf-8")) now = time.time() cursor.execute(""" UPDATE projects SET name = ?, data_json = ?, size_bytes = ?, updated_at = ? WHERE id = ? AND user_id = ? - """, (req.name, req.data_json, new_size_bytes, now, project_id, user_id)) + """, (req.name, validated_data_json, new_size_bytes, now, project_id, user_id)) conn.commit() conn.close() return {"success": True, "message": "Đã cập nhật dự án thành công"} + +class RenderProjectRequest(BaseModel): + sample_rate: Optional[int] = 44100 + +@router.post("/cloud/{project_id}/render") +async def render_project_endpoint(project_id: str, req: RenderProjectRequest, current_user: dict = Depends(get_current_user)): + user_id = current_user["user_id"] + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT name, data_json FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id)) + row = cursor.fetchone() + conn.close() + + if not row: + raise HTTPException(status_code=404, detail="Không tìm thấy dự án để kết xuất") + + # Validate schema + validate_project_data(row["data_json"]) + + # Trigger Celery task + from app.tasks.worker import render_project_task + task = render_project_task.delay( + project_id=project_id, + project_name=row["name"], + project_json_str=row["data_json"], + sample_rate=req.sample_rate or 44100 + ) + + return { + "task_id": task.id, + "status": "processing" + } diff --git a/app/core/render_engine.py b/app/core/render_engine.py new file mode 100644 index 0000000..61c9e25 --- /dev/null +++ b/app/core/render_engine.py @@ -0,0 +1,229 @@ +import os +import numpy as np +import soundfile as sf +from app.config import settings +from app.core.vst_engine import render_midi_events_to_audio + +import subprocess +import sys + +def check_pedalboard_safe(): + try: + res = subprocess.run( + [sys.executable, "-c", "import pedalboard"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2.0 + ) + return res.returncode == 0 + except Exception: + return False + +HAS_PEDALBOARD = check_pedalboard_safe() +if HAS_PEDALBOARD: + try: + from pedalboard import Pedalboard, Gain + except Exception: + HAS_PEDALBOARD = False + + + +class PythonRenderEngine: + def __init__(self, sample_rate=44100): + self.sample_rate = sample_rate + + def bars_to_samples(self, bars: float, bpm: float, time_sig_num: int) -> int: + seconds_per_beat = 60.0 / max(20.0, bpm) + seconds_per_bar = seconds_per_beat * time_sig_num + return int(bars * seconds_per_bar * self.sample_rate) + + def resolve_file_path(self, url_or_id: str) -> str: + if not url_or_id: + return "" + base = os.path.basename(url_or_id) + # Check uploads directory + p_uploads = os.path.join(settings.UPLOADS_DIR, base) + if os.path.exists(p_uploads): + return p_uploads + # Check processed directory + p_processed = os.path.join(settings.PROCESSED_DIR, base) + if os.path.exists(p_processed): + return p_processed + # Check general storage directory + p_storage = os.path.join(settings.STORAGE_DIR, base) + if os.path.exists(p_storage): + return p_storage + # Direct check + if os.path.exists(url_or_id): + return url_or_id + return url_or_id + + def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray: + session_buffer = np.zeros((2, total_samples), dtype=np.float32) + + for track in session.get("tracks", []): + track_type = track.get("type", "AUDIO") + track_buffer = np.zeros((2, total_samples), dtype=np.float32) + + for item in track.get("items", []): + start_sample = self.bars_to_samples(item["start_bar"], bpm, time_sig_num) + dur_samples = self.bars_to_samples(item["duration_bars"], bpm, time_sig_num) + offset_sample = self.bars_to_samples(item["clip_start_offset_bars"], bpm, time_sig_num) + + item_type = item.get("type") + if item_type == "AUDIO_ITEM": + source_data = item.get("source_data", {}) + audio_url = source_data.get("audio_file_url", "") + resolved_path = self.resolve_file_path(audio_url) + + if resolved_path and os.path.exists(resolved_path): + try: + audio_data, sr = sf.read(resolved_path, dtype='float32') + if sr != self.sample_rate: + # Resampling fallback if simple, otherwise skip + pass + + # Handle channel mapping (Mono/Stereo) + if len(audio_data.shape) == 1: + audio_data = np.vstack([audio_data, audio_data]) + else: + audio_data = audio_data.T # Shape: (channels, samples) + + # Trim source offset & duration + src_len = audio_data.shape[1] + if offset_sample < src_len: + actual_dur = min(dur_samples, src_len - offset_sample) + sliced_audio = audio_data[:, offset_sample : offset_sample + actual_dur] + + # Apply gain + gain_val = source_data.get("gain", 1.0) + sliced_audio = sliced_audio * gain_val + + # Write to track buffer with boundaries + write_end = min(start_sample + sliced_audio.shape[1], total_samples) + actual_len = write_end - start_sample + if actual_len > 0: + track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len] + except Exception as e: + print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}") + + elif item_type == "MIDI_ITEM": + source_data = item.get("source_data", {}) + notes = source_data.get("notes", []) + + # Convert to midi events required by vst_engine + midi_events = [] + for note in notes: + note_start_bar = note["start_beat"] / time_sig_num + # Filter notes within the non-destructive visible window + offset_bar = item["clip_start_offset_bars"] + dur_bar = item["duration_bars"] + if note_start_bar >= offset_bar and note_start_bar < (offset_bar + dur_bar): + rel_bar_in_item = note_start_bar - offset_bar + target_global_bar = item["start_bar"] + rel_bar_in_item + midi_events.append({ + "note": note["pitch"], + "start_beat": target_global_bar * time_sig_num, + "duration_beats": note["duration_beats"], + "velocity": int(note.get("velocity", 0.8) * 127) + }) + + if midi_events: + try: + # Synthesize MIDI track notes + synth_buffer = render_midi_events_to_audio( + midi_events=midi_events, + sr=self.sample_rate, + bpm=bpm, + instrument='synth' + ) + # Add to track buffer + actual_len = min(synth_buffer.shape[1], total_samples) + track_buffer[:, :actual_len] += synth_buffer[:, :actual_len] + except Exception as e: + print(f"[RenderEngine] Error rendering MIDI: {e}") + + elif item_type == "SECTION_ITEM": + source_data = item.get("source_data", {}) + sec_id = source_data.get("referenced_section_id", "") + if sec_id and sec_id in section_store: + # Render nested section recursively + sec_container = section_store[sec_id] + sec_buffer = self.render_session_container( + session=sec_container, + section_store=section_store, + bpm=bpm, + time_sig_num=time_sig_num, + total_samples=total_samples + ) + + # Apply non-destructive crop/slicing on section buffer + if offset_sample < total_samples: + actual_dur = min(dur_samples, total_samples - offset_sample) + sliced_sec = sec_buffer[:, offset_sample : offset_sample + actual_dur] + + # Write to track buffer + write_end = min(start_sample + sliced_sec.shape[1], total_samples) + actual_len = write_end - start_sample + if actual_len > 0: + track_buffer[:, start_sample:write_end] += sliced_sec[:, :actual_len] + + # Apply Track Gain (via Pedalboard or fallback) + vol_db = track.get("volume_db", 0.0) + pan = track.get("pan", 0.0) + mute = track.get("mute", False) + + if mute: + continue + + # Process track volume + if HAS_PEDALBOARD: + try: + board = Pedalboard([Gain(gain_db=vol_db)]) + processed_track = board(track_buffer, sample_rate=self.sample_rate) + except Exception: + gain_linear = 10 ** (vol_db / 20.0) + processed_track = track_buffer * gain_linear + else: + gain_linear = 10 ** (vol_db / 20.0) + processed_track = track_buffer * gain_linear + + # Apply Track Pan + if pan != 0.0: + # Constant power panning + theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0) + processed_track[0, :] *= np.cos(theta) + processed_track[1, :] *= np.sin(theta) + + # Mix track to session + session_buffer += processed_track + + return session_buffer + + def render_project(self, project_json: dict, output_filepath: str): + bpm = project_json["metadata"]["bpm"] + time_sig_num = project_json["metadata"].get("time_signature_numerator", 4) + main_session = project_json["main_session"] + section_store = project_json.get("section_store", {}) + + # Compute total project samples + total_bars = main_session.get("length_bars", 16.0) + total_samples = self.bars_to_samples(total_bars, bpm, time_sig_num) + + # Render main session + master_buffer = self.render_session_container( + session=main_session, + section_store=section_store, + bpm=bpm, + time_sig_num=time_sig_num, + total_samples=total_samples + ) + + # Normalization to prevent clipping + max_peak = np.max(np.abs(master_buffer)) + if max_peak > 1.0: + master_buffer /= max_peak + + # Write final output file + sf.write(output_filepath, master_buffer.T, self.sample_rate) + return output_filepath diff --git a/app/models/project_schema.json b/app/models/project_schema.json new file mode 100644 index 0000000..27d1bb3 --- /dev/null +++ b/app/models/project_schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DAWProject", + "type": "object", + "properties": { + "project_id": { "type": "string" }, + "metadata": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "bpm": { "type": "number", "minimum": 20.0, "maximum": 999.0, "default": 120.0 }, + "time_signature_numerator": { "type": "integer", "default": 4 }, + "time_signature_denominator": { "type": "integer", "default": 4 }, + "sample_rate": { "type": "integer", "default": 44100 } + }, + "required": ["title", "bpm", "time_signature_numerator", "time_signature_denominator", "sample_rate"] + }, + "main_session": { "$ref": "#/definitions/SessionContainer" }, + "section_store": { + "type": "object", + "description": "Auxiliary registry mapping section_id to sub-session containers", + "additionalProperties": { "$ref": "#/definitions/SessionContainer" } + } + }, + "required": ["project_id", "metadata", "main_session", "section_store"], + "definitions": { + "SessionContainer": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "is_root": { "type": "boolean" }, + "length_bars": { "type": "number", "description": "Computed or manually set total length in bars" }, + "auto_compute_length": { "type": "boolean", "default": true }, + "tracks": { + "type": "array", + "items": { "$ref": "#/definitions/Track" } + } + }, + "required": ["id", "is_root", "tracks"] + }, + "Track": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] }, + "volume_db": { "type": "number", "default": 0.0 }, + "pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 }, + "mute": { "type": "boolean", "default": false }, + "solo": { "type": "boolean", "default": false }, + "fx_chain": { + "type": "array", + "items": { "$ref": "#/definitions/FXPlugin" } + }, + "synth_engine": { "$ref": "#/definitions/SynthPlugin" }, + "items": { + "type": "array", + "items": { "$ref": "#/definitions/TimelineItem" } + } + }, + "required": ["id", "name", "type", "items"] + }, + "TimelineItem": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": "string", "enum": ["AUDIO_ITEM", "MIDI_ITEM", "SECTION_ITEM"] }, + "start_bar": { "type": "number", "description": "Global timeline position where the item starts" }, + "duration_bars": { "type": "number", "description": "Visible duration on the track timeline in bars" }, + "clip_start_offset_bars": { "type": "number", "description": "Internal start offset inside the source buffer/item" }, + "source_data": { + "type": "object", + "oneOf": [ + { "$ref": "#/definitions/AudioSourceData" }, + { "$ref": "#/definitions/MIDISourceData" }, + { "$ref": "#/definitions/SectionSourceData" } + ] + } + }, + "required": ["id", "type", "start_bar", "duration_bars", "clip_start_offset_bars", "source_data"] + }, + "AudioSourceData": { + "type": "object", + "properties": { + "audio_file_url": { "type": "string" }, + "sample_rate": { "type": "integer" }, + "channels": { "type": "integer" }, + "gain": { "type": "number", "default": 1.0 } + }, + "required": ["audio_file_url"] + }, + "MIDISourceData": { + "type": "object", + "properties": { + "total_buffer_bars": { "type": "number", "default": 8.0 }, + "notes": { + "type": "array", + "items": { "$ref": "#/definitions/MIDINote" } + } + }, + "required": ["total_buffer_bars", "notes"] + }, + "SectionSourceData": { + "type": "object", + "properties": { + "referenced_section_id": { "type": "string", "description": "Pointer to section_store key" } + }, + "required": ["referenced_section_id"] + }, + "MIDINote": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "pitch": { "type": "integer", "minimum": 0, "maximum": 127 }, + "start_beat": { "type": "number", "description": "Beat offset relative to the start of the source buffer (bar 0)" }, + "duration_beats": { "type": "number" }, + "velocity": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.8 }, + "pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 } + }, + "required": ["id", "pitch", "start_beat", "duration_beats", "velocity"] + }, + "FXPlugin": { + "type": "object", + "properties": { + "plugin_id": { "type": "string" }, + "name": { "type": "string" }, + "bypass": { "type": "boolean", "default": false }, + "parameters": { "type": "object" } + } + }, + "SynthPlugin": { + "type": "object", + "properties": { + "plugin_id": { "type": "string" }, + "preset_id": { "type": "string" }, + "parameters": { "type": "object" } + } + } + } +} diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 5643916..3b08081 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -2,7 +2,8 @@ const { useState, useRef, useEffect, - useMemo + useMemo, + useCallback } = React; // ── FastAPI Backend Configuration ── @@ -187,6 +188,7 @@ const WaveformLane = ({ onSectionItemDragStart, onSectionItemResizeStart, onEditSectionInTab, + onEditMidiInTab, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, @@ -827,6 +829,18 @@ const WaveformLane = ({ if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id); return; } + // Check double-click on MIDI item next + const dblMidiItems = track.midiItems || []; + let dblMidiHit = null; + for (const midi of dblMidiItems) { + if (time >= midi.startTime && time < midi.startTime + midi.duration) { dblMidiHit = midi; break; } + } + if (dblMidiHit) { + e.preventDefault(); + e.stopPropagation(); + if (onEditMidiInTab) onEditMidiInTab(track.id, dblMidiHit.id); + return; + } const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); if (clickedClip) { e.preventDefault(); @@ -2958,28 +2972,47 @@ const ProfileModal = ({ }; const handleOpenProject = async (projectId) => { - if (!confirm("Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.")) return; - try { - const proj = await window.SonicAPI.getCloudProject(projectId); - const parsed = JSON.parse(proj.data_json); - const restored = (parsed.tracks || []).map(t => ({ - ...t, - buffer: null, - channelInfo: t.channelInfo || null, - clips: t.clips || [], - serverFileId: t.serverFileId || null - })); - setTracks(restored); - setSelectedTrackId(restored[0]?.id || '1'); - setProjectName(proj.name); - setCurrentProjectId(proj.id); - localStorage.setItem('sonic_project_name', proj.name); - localStorage.setItem('sonic_project_id', proj.id); - showToast(`Đã nạp dự án "${proj.name}" thành công!`, "success"); - onClose(); - } catch (e) { - showToast(e.message || "Lỗi khi nạp dự án", "error"); - } + setAppWarningModal({ + title: "Mở dự án", + message: "Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.", + isAlert: false, + onConfirm: async () => { + try { + const proj = await window.SonicAPI.getCloudProject(projectId); + const parsed = JSON.parse(proj.data_json); + let restoredTracks = []; + let restoredBpm = bpm; + let restoredSessionTabs = []; + if (parsed.main_session) { + const result = deserializeProjectFromSchema(parsed); + restoredTracks = result.tracks; + restoredBpm = result.bpm; + restoredSessionTabs = result.sessionTabs; + } else { + restoredTracks = (parsed.tracks || []).map(t => ({ + ...t, + buffer: null, + channelInfo: t.channelInfo || null, + clips: t.clips || [], + serverFileId: t.serverFileId || null + })); + } + setTracks(restoredTracks); + setBpm(restoredBpm.toString()); + setSelectedTrackId(restoredTracks[0]?.id || '1'); + setProjectName(proj.name); + setCurrentProjectId(proj.id); + if (restoredSessionTabs.length > 0) { + setSessionTabs(restoredSessionTabs); + } + localStorage.setItem('sonic_project_name', proj.name); + localStorage.setItem('sonic_project_id', proj.id); + showToast(`Đã nạp dự án "${proj.name}" thành công!`, "success"); + } catch (e) { + showToast(e.message || "Lỗi khi nạp dự án", "error"); + } + } + }); }; const handleDeleteProject = async (projectId, e) => { @@ -3507,6 +3540,676 @@ const SystemManagerModal = ({ className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs" }, "Xóa"))))))))); }; +const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes }) => { + const [activeRollTool, setActiveRollTool] = React.useState('select'); + const [snapVal, setSnapVal] = React.useState('1/16'); + const [ccMode, setCcMode] = React.useState('velocity'); + const [rollZoom, setRollZoom] = React.useState(60); // local horizontal zoom factor + + const canvasRef = React.useRef(null); + const ccCanvasRef = React.useRef(null); + const gridScrollRef = React.useRef(null); + const keybedRef = React.useRef(null); + + const NoteHeight = 18; + const pixelsPerBeat = rollZoom; + const timeSigNum = 4; + const totalBeats = (st.duration || 4) * timeSigNum; + const drawWidth = totalBeats * pixelsPerBeat; + + const [notes, setNotes] = React.useState(st.notes || []); + const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart } + const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1); + + React.useEffect(() => { + onUpdateNotes(st.id, notes); + }, [notes]); + + const getSnapBeat = (beat, mode) => { + let q = 0.25; + if (mode === 'free') return beat; + if (mode === '1') q = 4.0; + else if (mode === '1/2') q = 2.0; + else if (mode === '1/4') q = 1.0; + else if (mode === '1/8') q = 0.5; + else if (mode === '1/16') q = 0.25; + else if (mode === '1/32') q = 0.125; + return Math.round(beat / q) * q; + }; + + const getSnapDuration = (mode) => { + if (mode === 'free') return 0.25; + if (mode === '1') return 4.0; + if (mode === '1/2') return 2.0; + if (mode === '1/4') return 1.0; + if (mode === '1/8') return 0.5; + if (mode === '1/16') return 0.25; + if (mode === '1/32') return 0.125; + return 0.25; + }; + + // Local Zoom Wheel Event handler to block browser page zoom + React.useEffect(() => { + const handleWheelRaw = (e) => { + if (e.ctrlKey) { + e.preventDefault(); + const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85; + setRollZoom(prev => Math.max(15, Math.min(250, prev * zoomFactor))); + } + }; + const container = gridScrollRef.current; + if (container) { + container.addEventListener('wheel', handleWheelRaw, { passive: false }); + } + return () => { + if (container) { + container.removeEventListener('wheel', handleWheelRaw); + } + }; + }, []); + + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const h = 128 * NoteHeight; + canvas.width = drawWidth * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + // Draw background rows + for (let pitch = 0; pitch < 128; pitch++) { + const y = (127 - pitch) * NoteHeight; + const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12); + ctx.fillStyle = isBlack ? '#1a1a1e' : '#25252a'; + ctx.fillRect(0, y, drawWidth, NoteHeight); + + ctx.strokeStyle = '#2d2d35'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, y + NoteHeight); + ctx.lineTo(drawWidth, y + NoteHeight); + ctx.stroke(); + } + + // Draw snap lines + let snapBeats = 0.25; + if (snapVal === '1') snapBeats = 4.0; + else if (snapVal === '1/2') snapBeats = 2.0; + else if (snapVal === '1/4') snapBeats = 1.0; + else if (snapVal === '1/8') snapBeats = 0.5; + else if (snapVal === '1/16') snapBeats = 0.25; + else if (snapVal === '1/32') snapBeats = 0.125; + + for (let beat = 0; beat <= totalBeats; beat += snapBeats) { + const x = beat * pixelsPerBeat; + const isBar = beat % timeSigNum === 0; + ctx.strokeStyle = isBar ? '#444450' : '#2d2d35'; + ctx.lineWidth = isBar ? 1.2 : 0.6; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + + // Draw notes with velocity layer representation (Hiển thị layer velocity ngay trên nốt) + notes.forEach((note) => { + const x = note.start_beat * pixelsPerBeat; + const y = (127 - note.pitch) * NoteHeight; + const w = note.duration_beats * pixelsPerBeat; + + // Draw background of note (lighter yellow) + ctx.fillStyle = 'rgba(234, 179, 8, 0.25)'; + ctx.strokeStyle = '#ca8a04'; + ctx.lineWidth = 1; + ctx.fillRect(x + 1, y + 1, w - 2, NoteHeight - 2); + ctx.strokeRect(x + 1, y + 1, w - 2, NoteHeight - 2); + + // Draw velocity layer (solid yellow bar inside, proportional to velocity) + const vel = note.velocity !== undefined ? note.velocity : 0.8; + const velW = Math.max(2, (w - 2) * vel); + ctx.fillStyle = '#eab308'; + ctx.fillRect(x + 1, y + 1, velW, NoteHeight - 2); + }); + }, [notes, snapVal, rollZoom]); + + React.useEffect(() => { + const canvas = ccCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const h = 80; + canvas.width = drawWidth * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + ctx.fillStyle = '#161616'; + ctx.fillRect(0, 0, drawWidth, h); + + ctx.strokeStyle = '#252525'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h / 2); + ctx.lineTo(drawWidth, h / 2); + ctx.stroke(); + + notes.forEach((note) => { + const x = note.start_beat * pixelsPerBeat + (note.duration_beats * pixelsPerBeat) / 2; + let val = note.velocity !== undefined ? note.velocity : 0.8; + if (ccMode === 'pan') { + val = (note.pan !== undefined ? note.pan : 0.0) * 0.5 + 0.5; + } + + const stemH = val * (h - 20) + 10; + const y = h - stemH; + + ctx.strokeStyle = ccMode === 'pan' ? '#a78bfa' : '#fbbf24'; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(x, h); + ctx.lineTo(x, y); + ctx.stroke(); + + ctx.fillStyle = ccMode === 'pan' ? '#c084fc' : '#fbbf24'; + ctx.beginPath(); + ctx.arc(x, y, 3.5, 0, 2 * Math.PI); + ctx.fill(); + }); + }, [notes, ccMode, rollZoom]); + + React.useEffect(() => { + if (gridScrollRef.current) { + gridScrollRef.current.scrollTop = (127 - 60) * NoteHeight - 150; + } + }, []); + + const handleGridMouseDown = (e) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const beat = x / pixelsPerBeat; + const pitch = 127 - Math.floor(y / NoteHeight); + + if (hoveredResizeIdx !== -1) { + setDraggedNote({ + mode: 'resize', + idx: hoveredResizeIdx, + originalStart: notes[hoveredResizeIdx].start_beat + }); + return; + } + + const clickedNoteIdx = notes.findIndex(n => { + return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats; + }); + + if (activeRollTool === 'select') { + if (clickedNoteIdx !== -1) { + setDraggedNote({ + mode: 'move', + idx: clickedNoteIdx, + startOffsetBeat: beat - notes[clickedNoteIdx].start_beat, + originalPitch: notes[clickedNoteIdx].pitch + }); + } else { + const start = getSnapBeat(beat, snapVal); + const newNote = { + id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5), + pitch: pitch, + start_beat: start, + duration_beats: getSnapDuration(snapVal), + velocity: 0.8, + pan: 0.0 + }; + setNotes(prev => [...prev, newNote]); + } + } else if (activeRollTool === 'eraser') { + if (clickedNoteIdx !== -1) { + setNotes(prev => prev.filter((_, idx) => idx !== clickedNoteIdx)); + } + } else if (activeRollTool === 'pen') { + if (clickedNoteIdx === -1) { + const start = getSnapBeat(beat, snapVal); + const newNote = { + id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5), + pitch: pitch, + start_beat: start, + duration_beats: getSnapDuration(snapVal), + velocity: 0.8, + pan: 0.0 + }; + setNotes(prev => [...prev, newNote]); + } + } + }; + + const handleGridMouseMove = (e) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const beat = x / pixelsPerBeat; + const pitch = 127 - Math.floor(y / NoteHeight); + + if (!draggedNote) { + // Check if mouse is near the right edge of any note + let foundIdx = -1; + for (let i = 0; i < notes.length; i++) { + const n = notes[i]; + if (pitch === n.pitch) { + const noteRightBeat = n.start_beat + n.duration_beats; + const pixelDist = Math.abs((noteRightBeat - beat) * pixelsPerBeat); + if (pixelDist < 6 && beat >= n.start_beat) { + foundIdx = i; + break; + } + } + } + if (foundIdx !== -1) { + canvas.style.cursor = 'ew-resize'; + setHoveredResizeIdx(foundIdx); + } else { + canvas.style.cursor = activeRollTool === 'eraser' ? 'pointer' : 'crosshair'; + setHoveredResizeIdx(-1); + } + return; + } + + if (draggedNote.mode === 'resize') { + const newDuration = getSnapBeat(Math.max(0.125, beat - draggedNote.originalStart), snapVal); + setNotes(prev => prev.map((n, idx) => { + if (idx !== draggedNote.idx) return n; + return { + ...n, + duration_beats: newDuration + }; + })); + } else { + const newStart = getSnapBeat(Math.max(0, beat - draggedNote.startOffsetBeat), snapVal); + const newPitch = Math.max(0, Math.min(127, pitch)); + setNotes(prev => prev.map((n, idx) => { + if (idx !== draggedNote.idx) return n; + return { + ...n, + start_beat: newStart, + pitch: newPitch + }; + })); + } + }; + + const handleGridMouseUp = () => { + setDraggedNote(null); + }; + + const handleCCMouseDown = (e) => { + const canvas = ccCanvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const h = rect.height; + const beat = x / pixelsPerBeat; + + // Find note near beat or closest note horizontally + let noteIdx = notes.findIndex(n => beat >= n.start_beat && beat <= n.start_beat + n.duration_beats); + if (noteIdx === -1) { + let minDistance = Infinity; + notes.forEach((n, idx) => { + const center = n.start_beat + n.duration_beats / 2; + const dist = Math.abs(center - beat); + if (dist < minDistance) { + minDistance = dist; + noteIdx = idx; + } + }); + } + + if (noteIdx !== -1) { + const val = Math.max(0, Math.min(1, (h - y) / h)); + setNotes(prev => prev.map((n, idx) => { + if (idx !== noteIdx) return n; + if (ccMode === 'pan') { + return { ...n, pan: (val - 0.5) * 2.0 }; + } else { + return { ...n, velocity: val }; + } + })); + } + }; + + const renderKeybed = () => { + const keys = []; + for (let pitch = 127; pitch >= 0; pitch--) { + const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12); + const notesArray = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const octave = Math.floor(pitch / 12) - 1; + const label = `${notesArray[pitch % 12]}${octave}`; + const showLabel = pitch % 12 === 0; + + keys.push( + /*#__PURE__*/React.createElement("div", { + key: pitch, + style: { height: `${NoteHeight}px` }, + className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900' : 'bg-white text-zinc-800 border-r border-zinc-400'}` + }, showLabel && label) + ); + } + return keys; + }; + + const handleScroll = (e) => { + if (keybedRef.current) { + keybedRef.current.scrollTop = e.currentTarget.scrollTop; + } + }; + + return /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col" + }, /*#__PURE__*/React.createElement("div", { + className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-4" + }, /*#__PURE__*/React.createElement("span", { + className: "text-xs font-bold text-yellow-500 uppercase flex items-center gap-1.5" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "music", + className: "w-3.5 h-3.5" + }), st.label), /*#__PURE__*/React.createElement("div", { + className: "flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs" + }, ['select', 'pen', 'eraser'].map(tool => /*#__PURE__*/React.createElement("button", { + key: tool, + onClick: () => setActiveRollTool(tool), + className: `px-2.5 py-1 rounded capitalize ${activeRollTool === tool ? 'bg-yellow-600 text-white font-bold' : 'text-zinc-400 hover:text-zinc-200'}` + }, tool))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1 text-xs" + }, /*#__PURE__*/React.createElement("span", { + className: "text-zinc-500 font-semibold" + }, "Snap:"), /*#__PURE__*/React.createElement("select", { + value: snapVal, + onChange: e => setSnapVal(e.target.value), + className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500" + }, ['free', '1', '1/2', '1/4', '1/8', '1/16', '1/32'].map(v => /*#__PURE__*/React.createElement("option", { + key: v, + value: v + }, v)))), /*#__PURE__*/React.createElement("div", { + className: "flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs" + }, ['velocity', 'pan'].map(mode => /*#__PURE__*/React.createElement("button", { + key: mode, + onClick: () => setCcMode(mode), + className: `px-2.5 py-1 rounded capitalize ${ccMode === mode ? 'bg-purple-900/60 text-purple-300 font-bold border border-purple-700' : 'text-zinc-400 hover:text-zinc-200'}` + }, mode)))), /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + }), "Đóng")), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex overflow-hidden min-h-0 relative" + }, /*#__PURE__*/React.createElement("div", { + ref: keybedRef, + className: "w-[60px] overflow-hidden flex flex-col border-r border-zinc-900 shrink-0", + style: { + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, renderKeybed()), /*#__PURE__*/React.createElement("div", { + ref: gridScrollRef, + onScroll: handleScroll, + className: "flex-1 overflow-auto bg-[#141414] min-w-0" + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${drawWidth}px`, + height: `${128 * NoteHeight}px` + }, + className: "relative" + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + onMouseDown: handleGridMouseDown, + onMouseMove: handleGridMouseMove, + onMouseUp: handleGridMouseUp, + onMouseLeave: handleGridMouseUp, + className: "absolute inset-0 cursor-crosshair" + })))), /*#__PURE__*/React.createElement("div", { + className: "h-20 bg-[#161616] border-t border-zinc-900 flex shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "w-[60px] bg-[#222222] border-r border-zinc-900 flex items-center justify-center text-[10px] text-zinc-500 font-mono shrink-0 font-bold" + }, ccMode.toUpperCase()), /*#__PURE__*/React.createElement("div", { + className: "flex-1 overflow-x-hidden min-w-0" + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${drawWidth}px`, + height: '100%' + }, + className: "relative" + }, /*#__PURE__*/React.createElement("canvas", { + ref: ccCanvasRef, + onMouseDown: handleCCMouseDown, + className: "absolute inset-0 cursor-ns-resize" + }))))); +}; + +const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsList, sessionTabsList) => { + const secondsPerBar = (60.0 / parseFloat(bpmVal || 120)) * 4; + const mainTracks = (tracksList || []).map(t => { + let trackType = "AUDIO"; + if (t.sections && t.sections.length > 0) trackType = "SECTION"; + else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI"; + + const items = []; + if (trackType === "AUDIO" && t.clips) { + t.clips.forEach(c => { + const durationSec = c.buffer ? c.buffer.duration : 4.0; + items.push({ + id: c.id, + name: c.name || "Audio Clip", + type: "AUDIO_ITEM", + start_bar: c.startTime / secondsPerBar, + duration_bars: (durationSec / (c.speed || 1.0)) / secondsPerBar, + clip_start_offset_bars: 0.0, + source_data: { + audio_file_url: t.serverFileId ? `/static/audio/uploads/${t.serverFileId}` : "", + sample_rate: c.buffer ? c.buffer.sampleRate : 44100, + channels: c.buffer ? c.buffer.numberOfChannels : 2, + gain: 1.0 + } + }); + }); + } else if (trackType === "MIDI" && t.midiItems) { + t.midiItems.forEach(m => { + items.push({ + id: m.id, + name: m.name || "MIDI Item", + type: "MIDI_ITEM", + start_bar: m.startTime / secondsPerBar, + duration_bars: m.duration || 4.0, + clip_start_offset_bars: 0.0, + source_data: { + total_buffer_bars: m.duration || 8.0, + notes: (m.notes || []).map(n => ({ + id: n.id || 'note_' + Math.random().toString(36).substr(2, 9), + pitch: n.pitch || 60, + start_beat: n.start_beat || 0.0, + duration_beats: n.duration_beats || 1.0, + velocity: n.velocity || 0.8, + pan: n.pan || 0.0 + })) + } + }); + }); + } else if (trackType === "SECTION" && t.sections) { + t.sections.forEach(s => { + items.push({ + id: s.id, + name: s.name || "Section Item", + type: "SECTION_ITEM", + start_bar: s.start / secondsPerBar, + duration_bars: s.duration / secondsPerBar, + clip_start_offset_bars: 0.0, + source_data: { + referenced_section_id: s.sectionId || s.id + } + }); + }); + } + + return { + id: t.id, + name: t.name, + type: trackType, + volume_db: t.volumeDb || 0.0, + pan: t.pan || 0.0, + mute: t.muted || false, + solo: t.solo || false, + items: items + }; + }); + + const sectionStore = {}; + (sessionTabsList || []).forEach(st => { + const secTracks = (st.tracks || []).map(t => { + let trackType = "AUDIO"; + if (t.sections && t.sections.length > 0) trackType = "SECTION"; + else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI"; + return { + id: t.id, + name: t.name, + type: trackType, + volume_db: t.volumeDb || 0.0, + pan: t.pan || 0.0, + mute: t.muted || false, + solo: t.solo || false, + items: [] + }; + }); + sectionStore[st.sectionId] = { + id: st.sectionId, + name: st.name, + is_root: false, + length_bars: st.length_bars || 16.0, + auto_compute_length: st.auto_compute_length !== undefined ? st.auto_compute_length : true, + tracks: secTracks + }; + }); + + return { + project_id: projectId || 'proj_' + Date.now(), + metadata: { + title: name || "Dự án mới", + bpm: parseFloat(bpmVal || 120), + time_signature_numerator: 4, + time_signature_denominator: 4, + sample_rate: 44100 + }, + main_session: { + id: "main", + name: "MAIN SESSION", + is_root: true, + length_bars: 16.0, + auto_compute_length: true, + tracks: mainTracks + }, + section_store: sectionStore + }; +}; + +const deserializeProjectFromSchema = (schemaObj) => { + const bpmVal = schemaObj.metadata ? schemaObj.metadata.bpm : 120; + const secondsPerBar = (60.0 / bpmVal) * 4; + + const restoredTracks = (schemaObj.main_session.tracks || []).map(t => { + const clips = []; + const sections = []; + const midiItems = []; + + (t.items || []).forEach(item => { + if (item.type === "AUDIO_ITEM") { + const src = item.source_data || {}; + const audioUrl = src.audio_file_url || ""; + const serverFileId = audioUrl.split("/").pop() || null; + clips.push({ + id: item.id, + name: item.name, + startTime: item.start_bar * secondsPerBar, + speed: 1.0 + }); + } else if (item.type === "MIDI_ITEM") { + const src = item.source_data || {}; + midiItems.push({ + id: item.id, + name: item.name, + startTime: item.start_bar * secondsPerBar, + duration: item.duration_bars, + notes: src.notes || [] + }); + } else if (item.type === "SECTION_ITEM") { + const src = item.source_data || {}; + sections.push({ + id: item.id, + name: item.name, + start: item.start_bar * secondsPerBar, + duration: item.duration_bars * secondsPerBar, + sectionId: src.referenced_section_id + }); + } + }); + + return { + id: t.id, + name: t.name, + volumeDb: t.volume_db || 0.0, + pan: t.pan || 0.0, + muted: t.mute || false, + solo: t.solo || false, + color: t.id === '1' ? '#0f766e' : '#1d4ed8', + markers: [], + serverFileId: t.items && t.items.find(i => i.type === "AUDIO_ITEM")?.source_data?.audio_file_url?.split("/").pop() || null, + clips: clips, + sections: sections, + midiItems: midiItems + }; + }); + + const restoredSessionTabs = []; + const sectionStore = schemaObj.section_store || {}; + Object.keys(sectionStore).forEach(secId => { + const secContainer = sectionStore[secId]; + const secTracks = (secContainer.tracks || []).map(t => { + return { + id: t.id, + name: t.name, + volumeDb: t.volume_db || 0.0, + pan: t.pan || 0.0, + muted: t.mute || false, + solo: t.solo || false, + clips: [], + sections: [], + midiItems: [] + }; + }); + restoredSessionTabs.push({ + id: 'session_' + secId, + name: secContainer.name, + sectionId: secId, + tracks: secTracks, + length_bars: secContainer.length_bars || 16.0, + auto_compute_length: secContainer.auto_compute_length + }); + }); + + return { + bpm: bpmVal, + tracks: restoredTracks, + sessionTabs: restoredSessionTabs + }; +}; + const App = () => { // ── State Definitions ── const [tracks, setTracks] = useState([{ @@ -3542,6 +4245,7 @@ const App = () => { sections: [], midiItems: [] }]); + const [appWarningModal, setAppWarningModal] = useState(null); const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color } const [hoveredTrackId, setHoveredTrackId] = useState(null); @@ -3821,16 +4525,30 @@ const App = () => { const proj = window.__pendingSfsProject; if (!proj) return; try { - const restored = (proj.tracks || []).map(t => ({ - ...t, - buffer: null, - channelInfo: t.channelInfo || null, - clips: t.clips || [], - serverFileId: t.serverFileId || null - })); - if (restored.length > 0) { - setTracks(restored); - showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success"); + let restoredTracks = []; + let restoredBpm = bpm; + let restoredSessionTabs = []; + if (proj.main_session) { + const result = deserializeProjectFromSchema(proj); + restoredTracks = result.tracks; + restoredBpm = result.bpm; + restoredSessionTabs = result.sessionTabs; + } else { + restoredTracks = (proj.tracks || []).map(t => ({ + ...t, + buffer: null, + channelInfo: t.channelInfo || null, + clips: t.clips || [], + serverFileId: t.serverFileId || null + })); + } + if (restoredTracks.length > 0) { + setTracks(restoredTracks); + setBpm(restoredBpm.toString()); + if (restoredSessionTabs.length > 0) { + setSessionTabs(restoredSessionTabs); + } + showToast(`Đã tải dự án "${proj.metadata?.title || proj.name || 'Dự án mới'}" từ liên kết .sfs thành công!`, "success"); } } catch (e) { showToast("Lỗi tải dự án từ .sfs", "error"); @@ -3908,31 +4626,10 @@ const App = () => { label: t.channelInfo.label } : null })); - window.SonicStorage.scheduleTempAutoSave(() => ({ - id: 'temp_project', - name: 'Dự án tạm chưa lưu', - tracks: serializeSafe(tracks), - subTabs: (subTabs || []).map(s => ({ - id: s.id, - label: s.label, - trackId: s.trackId, - clipId: s.clipId, - startTime: s.startTime, - endTime: s.endTime, - speed: s.speed, - fadeInLen: s.fadeInLen || 0, - fadeOutLen: s.fadeOutLen || 0, - graphMode: s.graphMode, - volumeNodes: s.volumeNodes || [], - panningNodes: s.panningNodes || [], - channelInfo: s.channelInfo ? { - channels: s.channelInfo.channels, - isStereo: s.channelInfo.isStereo, - label: s.channelInfo.label - } : null - })) - })); - }, [tracks, subTabs]); + window.SonicStorage.scheduleTempAutoSave(() => { + return serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs); + }); + }, [tracks, subTabs, sessionTabs]); // Lucide icons initialization useEffect(() => { @@ -3943,6 +4640,11 @@ const App = () => { }, 50); }, [activeTool, activeTab]); const timelineWrapperRef = useRef(null); + const [timelineWrapperNode, setTimelineWrapperNode] = useState(null); + const handleTimelineWrapperRef = useCallback(node => { + timelineWrapperRef.current = node; + setTimelineWrapperNode(node); + }, []); const tcpContainerRef = useRef(null); const [scrollLeft, setScrollLeft] = useState(0); const handleTimelineScroll = e => { @@ -4655,7 +5357,7 @@ const App = () => { setActiveTab(tabId); }; const handleEditClipInSubTab = (trackId, clipId) => { - const track = tracks.find(t => t.id === trackId); + const track = activeTracks.find(t => t.id === trackId); if (!track) return; const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default_' + track.id, @@ -4717,6 +5419,73 @@ const App = () => { setActiveTab(tabId); }; + const handleEditMidiInTab = (trackId, midiItemId) => { + const track = activeTracks.find(t => t.id === trackId); + if (!track) return; + const midiItem = (track.midiItems || []).find(m => m.id === midiItemId); + if (!midiItem) return; + const existing = subTabs.find(s => s.type === 'PIANO_ROLL' && s.target_id === midiItemId); + if (existing) { + setActiveTab(existing.id); + showToast('Piano Roll cho nốt MIDI đã được mở.', 'info'); + return; + } + const tabId = 'midi_' + Date.now(); + const tabLabel = `Piano Roll: ${midiItem.name || 'MIDI'}`; + const newTab = { + id: tabId, + label: tabLabel, + type: 'PIANO_ROLL', + trackId: trackId, + target_id: midiItemId, + parent_tab_id: activeTab === 'main' ? null : activeTab, + notes: midiItem.notes || [], + duration: midiItem.duration || 4, + viewport_start_bar: 0.0, + viewport_bar_width: 8.0, + scroll_y_pitch: 60, + snap_resolution: snapValue || "1/16", + note_selection: [] + }; + setSubTabs(prev => [...prev, newTab]); + setActiveTab(tabId); + }; + + const handleUpdateMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => { + setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: updatedNotes } : s)); + + const isSectionTrack = sessionTabs.some(s => s.tracks.some(t => t.id === trackId)); + if (isSectionTrack) { + setSessionTabs(prev => prev.map(s => { + if (!s.tracks.some(t => t.id === trackId)) return s; + return { + ...s, + tracks: s.tracks.map(t => { + if (t.id !== trackId) return t; + return { + ...t, + midiItems: (t.midiItems || []).map(m => { + if (m.id !== midiItemId) return m; + return { ...m, notes: updatedNotes }; + }) + }; + }) + }; + })); + } else { + setTracks(prev => prev.map(t => { + if (t.id !== trackId) return t; + return { + ...t, + midiItems: (t.midiItems || []).map(m => { + if (m.id !== midiItemId) return m; + return { ...m, notes: updatedNotes }; + }) + }; + })); + } + }; + // ── Double-click/Edit Section: open Main Session in new tab ── const handleEditSectionInTab = (trackId, sectionId) => { const track = tracks.find(t => t.id === trackId); @@ -4742,7 +5511,7 @@ const App = () => { const applySubTab = tabId => { const subTab = subTabs.find(s => s.id === tabId); if (!subTab || !subTab.buffer) return; - const track = tracks.find(t => t.id === subTab.trackId); + const track = activeTracks.find(t => t.id === subTab.trackId); if (!track || !track.buffer) return; const beforeSnap = captureTrackSnapshot(subTab.trackId); @@ -4882,63 +5651,136 @@ const App = () => { resultData = resultBuffer.getChannelData(0); resultData.set(newData); } - if (subTab.clipId) { - // Clip-based merge - setTracks(prev => prev.map(t => { - if (t.id !== subTab.trackId) return t; - const updatedClips = (t.clips || []).map(c => { - if (c.id === subTab.clipId) { - return { - ...c, - buffer: resultBuffer, - name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' - }; - } - return c; - }); - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer, - startTime: updatedClips[0]?.startTime || 0, - name: updatedClips[0]?.name || t.name - }; - })); - } else { - // Crossfade merge into original track (§2.2) - const sr = track.buffer.sampleRate; - const origData = track.buffer.getChannelData(0); - const trackStart = track.startTime || 0; - const startSample = Math.floor((subTab.startTime - trackStart) * sr); - const endSample = Math.floor((subTab.endTime - trackStart) * sr); - const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms + const isSectionTrack = sessionTabs.some(s => s.tracks.some(t => t.id === subTab.trackId)); + if (isSectionTrack) { + if (subTab.clipId) { + setSessionTabs(prev => prev.map(s => { + if (!s.tracks.some(t => t.id === subTab.trackId)) return s; + return { + ...s, + tracks: s.tracks.map(t => { + if (t.id !== subTab.trackId) return t; + const updatedClips = (t.clips || []).map(c => { + if (c.id === subTab.clipId) { + return { + ...c, + buffer: resultBuffer, + name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' + }; + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name + }; + }) + }; + })); + } else { + // Crossfade merge into original track (§2.2) + const sr = track.buffer.sampleRate; + const origData = track.buffer.getChannelData(0); + const trackStart = track.startTime || 0; + const startSample = Math.floor((subTab.startTime - trackStart) * sr); + const endSample = Math.floor((subTab.endTime - trackStart) * sr); + const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms - const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); - const mergedData = mergedBuffer.getChannelData(0); - for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; - for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; - for (let i = 0; i < resultData.length; i++) { - const globalIdx = startSample + i; - let val = resultData[i]; - if (i < crossfadeLen) { - const alpha = i / crossfadeLen; - val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i]; - } else if (i > resultData.length - crossfadeLen) { - const distFromEnd = resultData.length - 1 - i; - const alpha = distFromEnd / crossfadeLen; - const origEndIdx = endSample - (resultData.length - i); - val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i]; + const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); + const mergedData = mergedBuffer.getChannelData(0); + for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; + for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; + for (let i = 0; i < resultData.length; i++) { + const globalIdx = startSample + i; + let val = resultData[i]; + if (i < crossfadeLen) { + const alpha = i / crossfadeLen; + val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i]; + } else if (i > resultData.length - crossfadeLen) { + const distFromEnd = resultData.length - 1 - i; + const alpha = distFromEnd / crossfadeLen; + const origEndIdx = endSample - (resultData.length - i); + val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i]; + } + mergedData[globalIdx] = val; } - mergedData[globalIdx] = val; + setSessionTabs(prev => prev.map(s => { + if (!s.tracks.some(t => t.id === subTab.trackId)) return s; + return { + ...s, + tracks: s.tracks.map(t => { + if (t.id !== subTab.trackId) return t; + return { + ...t, + buffer: mergedBuffer, + name: t.name + ' (edited)' + }; + }) + }; + })); + } + } else { + if (subTab.clipId) { + // Clip-based merge + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + const updatedClips = (t.clips || []).map(c => { + if (c.id === subTab.clipId) { + return { + ...c, + buffer: resultBuffer, + name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' + }; + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name + }; + })); + } else { + // Crossfade merge into original track (§2.2) + const sr = track.buffer.sampleRate; + const origData = track.buffer.getChannelData(0); + const trackStart = track.startTime || 0; + const startSample = Math.floor((subTab.startTime - trackStart) * sr); + const endSample = Math.floor((subTab.endTime - trackStart) * sr); + const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms + + const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); + const mergedData = mergedBuffer.getChannelData(0); + for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; + for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; + for (let i = 0; i < resultData.length; i++) { + const globalIdx = startSample + i; + let val = resultData[i]; + if (i < crossfadeLen) { + const alpha = i / crossfadeLen; + val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i]; + } else if (i > resultData.length - crossfadeLen) { + const distFromEnd = resultData.length - 1 - i; + const alpha = distFromEnd / crossfadeLen; + const origEndIdx = endSample - (resultData.length - i); + val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i]; + } + mergedData[globalIdx] = val; + } + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + return { + ...t, + buffer: mergedBuffer, + name: t.name + ' (edited)' + }; + })); } - setTracks(prev => prev.map(t => { - if (t.id !== subTab.trackId) return t; - return { - ...t, - buffer: mergedBuffer, - name: t.name + ' (edited)' - }; - })); } const afterSnap = captureTrackSnapshot(subTab.trackId); pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap); @@ -5074,6 +5916,18 @@ const App = () => { if (activeTab === tabId) setActiveTab('main'); }; const closeSessionTab = tabId => { + const targetTab = sessionTabs.find(s => s.id === tabId); + if (!targetTab) return; + const activeChildTabs = subTabs.filter(s => s.parent_tab_id === tabId); + if (activeChildTabs.length > 0) { + setAppWarningModal({ + title: "Không thể đóng Tab Section", + message: `Vui lòng đóng các Tab con biên tập trước khi đóng Tab Section này:\n\n` + + activeChildTabs.map(t => `• ${t.label || t.name}`).join("\n"), + isAlert: true + }); + return; + } setSessionTabs(prev => prev.filter(s => s.id !== tabId)); if (activeTab === tabId) setActiveTab('main'); }; @@ -5153,9 +6007,23 @@ const App = () => { } }, [contextMenu]); const contextMenuEdit = () => { - const track = tracks.find(t => t.id === contextMenu.trackId); + const track = activeTracks.find(t => t.id === contextMenu.trackId); if (track) setSelectedTrackId(contextMenu.trackId); closeContextMenu(); + if (!track) return; + + const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; + + // Check MIDI items first + const midiItems = track.midiItems || []; + const clickedMidi = midiItems.find(m => contextMenu.time >= m.startTime && contextMenu.time < m.startTime + (m.duration || 4) * secondsPerBar); + + if (clickedMidi) { + handleEditMidiInTab(contextMenu.trackId, clickedMidi.id); + return; + } + + // Check Audio clips next const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default', buffer: track.buffer, @@ -5199,7 +6067,7 @@ const App = () => { } }; const contextMenuCopy = () => { - const track = tracks.find(t => t.id === contextMenu.trackId); + const track = activeTracks.find(t => t.id === contextMenu.trackId); if (!track || !track.buffer) return; const sr = track.buffer.sampleRate; const data = track.buffer.getChannelData(0); @@ -5590,16 +6458,15 @@ const App = () => { // ── Server Health Check ── const [viewportWidth, setViewportWidth] = useState(1200); useEffect(() => { - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; + if (!timelineWrapperNode) return; const observer = new ResizeObserver(entries => { for (let entry of entries) { setViewportWidth(entry.contentRect.width); } }); - observer.observe(wrapper); + observer.observe(timelineWrapperNode); return () => observer.disconnect(); - }, []); + }, [timelineWrapperNode]); useEffect(() => { fetch(API_BASE_URL).then(r => { if (r.ok) setServerStatus('connected');else setServerStatus('error'); @@ -7280,8 +8147,8 @@ const App = () => { setSaveProjectModalOpen(true); return; } - const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null })); - const dataJson = JSON.stringify({ id: currentProjectId || 'project_' + Date.now(), name: projectName, tracks: ss(tracks) }); + const projectSchemaObj = serializeProjectToSchema(currentProjectId || 'project_' + Date.now(), projectName, bpm, tracks, subTabs, sessionTabs); + const dataJson = JSON.stringify(projectSchemaObj); try { if (currentProjectId) { await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson); @@ -7303,8 +8170,8 @@ const App = () => { const handleSaveProjectWithName = async (newName) => { setProjectName(newName); localStorage.setItem('sonic_project_name', newName); - const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null })); - const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name: newName, tracks: ss(tracks) }); + const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs); + const dataJson = JSON.stringify(projectSchemaObj); try { const res = await window.SonicAPI.saveCloudProject(newName, dataJson); const newProjId = res.project_id; @@ -7319,8 +8186,8 @@ const App = () => { }; const handleSaveAsCloud = async (newName) => { - const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null })); - const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name: newName, tracks: ss(tracks) }); + const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs); + const dataJson = JSON.stringify(projectSchemaObj); try { const res = await window.SonicAPI.saveCloudProject(newName, dataJson); const newProjId = res.project_id; @@ -7339,29 +8206,8 @@ const App = () => { const handleSaveCloud = handleSaveProject; const handleExportSFS = (customName = null) => { const finalName = customName || projectName || 'Dự án SonicForge'; - const serializeSafe = arr => (arr || []).map(t => ({ - id: t.id, - name: t.name, - startTime: t.startTime, - height: t.height, - volumeDb: t.volumeDb, - pan: t.pan, - muted: t.muted, - solo: t.solo, - color: t.color, - markers: t.markers || [], - serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { - channels: t.channelInfo.channels, - isStereo: t.channelInfo.isStereo, - label: t.channelInfo.label - } : null - })); - window.SonicStorage.exportProjectToSFS({ - id: currentProjectId || 'proj_' + Date.now(), - name: finalName, - tracks: serializeSafe(tracks) - }); + const projectSchemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs); + window.SonicStorage.exportProjectToSFS(projectSchemaObj); showToast("Đã xuất dự án (.sfs) thành công!", "success"); }; const handleImportSFS = () => { @@ -10607,7 +11453,7 @@ const App = () => { })), /*#__PURE__*/React.createElement("div", { className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0" })), /*#__PURE__*/React.createElement("div", { - ref: timelineWrapperRef, + ref: handleTimelineWrapperRef, onScroll: handleTimelineScroll, className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" }, /*#__PURE__*/React.createElement("div", { @@ -10698,6 +11544,7 @@ const App = () => { onSplitTrackAtTime: handleSplitTrackAtTime, onEditClipInSubTab: handleEditClipInSubTab, onEditSectionInTab: handleEditSectionInTab, + onEditMidiInTab: handleEditMidiInTab, snapValue: snapValue, bpm: bpm, selectionMode: selectionMode, @@ -10781,6 +11628,16 @@ const App = () => { })))))) : (() => { const st = subTabs.find(s => s.id === activeTab); if (!st) return null; + if (st.type === 'PIANO_ROLL') { + return /*#__PURE__*/React.createElement(PianoRollTabEditor, { + st: st, + zoom: zoom, + bpm: bpm, + viewportWidth: viewportWidth, + onClose: () => closeSubTab(st.id), + onUpdateNotes: handleUpdateMidiNotes + }); + } const subTrack = tracks.find(t => t.id === st.trackId); const vTrack = subTrack ? { ...subTrack, @@ -11099,7 +11956,7 @@ const App = () => { })), " Save"))) : /*#__PURE__*/React.createElement("div", { className: "flex-1 flex items-center justify-center text-xs text-zinc-500" }, "Track not found")), /*#__PURE__*/React.createElement("div", { - ref: timelineWrapperRef, + ref: handleTimelineWrapperRef, className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" }, /*#__PURE__*/React.createElement("div", { style: { @@ -11539,7 +12396,32 @@ const App = () => { setToastMessage(null); }, className: "ml-2 px-2 py-0.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-[10px] uppercase font-bold" - }, toastMessage.actionText || 'Tải về'))), /*#__PURE__*/React.createElement(AuthModal, { + }, toastMessage.actionText || 'Tải về'))), appWarningModal && /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#222222] border border-zinc-800 rounded-lg shadow-2xl p-6 max-w-md w-full text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2 mb-3 text-cyan-400 font-bold" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-base font-bold" + }, appWarningModal.title)), /*#__PURE__*/React.createElement("div", { + className: "text-xs text-slate-300 mb-5 leading-relaxed whitespace-pre-line" + }, appWarningModal.message), /*#__PURE__*/React.createElement("div", { + className: "flex justify-end gap-2" + }, appWarningModal.isAlert ? /*#__PURE__*/React.createElement("button", { + onClick: () => setAppWarningModal(null), + className: "px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition" + }, "Đóng") : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("button", { + onClick: () => setAppWarningModal(null), + className: "px-4 py-2 bg-zinc-700 hover:bg-zinc-600 active:bg-zinc-800 text-slate-300 rounded text-xs font-semibold transition" + }, "Hủy"), /*#__PURE__*/React.createElement("button", { + onClick: () => { + const fn = appWarningModal.onConfirm; + setAppWarningModal(null); + if (fn) fn(); + }, + className: "px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition" + }, "Xác nhận"))))), /*#__PURE__*/React.createElement(AuthModal, { isOpen: authModalOpen, mode: authMode, forceMandatory: isMandatoryLogin, diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 6742eda..379cc8f 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -1,12152 +1,267 @@ -const { - useState, - useRef, - useEffect, - useMemo -} = React; - -// ── FastAPI Backend Configuration ── -const API_BASE_URL = window.location.origin; -const API_AUDIO = `${API_BASE_URL}/api/v1/audio`; -const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`; -const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`; - -// Handle ?sfs= from double-clicking a .sfs file (opens domain -> loads project) -(function handleSfsDeepLink() { - try { - const params = new URLSearchParams(window.location.search); - const sfsParam = params.get('sfs'); - if (!sfsParam) return; - const decoded = JSON.parse(decodeURIComponent(sfsParam)); - window.__pendingSfsProject = decoded; // consumed after auth in App - if (window.history.replaceState) { - window.history.replaceState({}, document.title, window.location.pathname); - } - } catch (e) { - window.__pendingSfsProject = null; - } -})(); - -// Storage for server-side file IDs mapped to track IDs -let serverFileIdMap = {}; -let audioCtx; -function getAudioContext() { - if (!audioCtx) { - audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - if (window.SonicAudio && window.SonicAudio.initAudioWorklet) { - window.SonicAudio.initAudioWorklet(); - } - } - if (audioCtx.state === 'suspended') { - audioCtx.resume(); - } - return audioCtx; -} -const formatTime = secs => { - if (isNaN(secs) || secs < 0) return "0:00.000"; - const m = Math.floor(secs / 60); - const s = Math.floor(secs % 60); - const ms = Math.floor(secs % 1 * 1000).toString().padStart(3, '0'); - return `${m}:${s.toString().padStart(2, '0')}.${ms}`; -}; -const formatTimeSimple = secs => { - if (isNaN(secs) || secs < 0) return "0.00s"; - return `${secs.toFixed(2)}s`; -}; -const formatBeat = (secs, bpmVal) => { - if (isNaN(secs) || secs < 0) return "1.1.1"; - const beatDuration = 60 / bpmVal; - const barDuration = beatDuration * 4; - const bar = Math.floor(secs / barDuration) + 1; - const beat = Math.floor(secs % barDuration / beatDuration) + 1; - const sub = Math.floor(secs % beatDuration / (beatDuration / 4)) + 1; - return `${bar}.${beat}.${sub}`; -}; -const getBeatMarkers = (maxDur, bpmVal) => { - const beatDuration = 60 / bpmVal; - const barDuration = beatDuration * 4; - const markers = []; - for (let t = 0; t <= maxDur; t += beatDuration) { - const isBar = Math.abs(t % barDuration) < 0.001 || Math.abs(t % barDuration - barDuration) < 0.001; - markers.push({ - time: t, - isBar, - beatNum: Math.floor(t / beatDuration) + 1 - }); - } - return markers; -}; -const findZeroCrossing = (buffer, targetTime) => { - if (!buffer) return targetTime; - const sampleRate = buffer.sampleRate; - const data = buffer.getChannelData(0); - const targetSample = Math.floor(targetTime * sampleRate); - const windowSize = Math.floor(0.04 * sampleRate); - const start = Math.max(0, targetSample - windowSize); - const end = Math.min(data.length - 2, targetSample + windowSize); - let bestSample = targetSample; - let minDistance = Infinity; - for (let i = start; i <= end; i++) { - if (data[i] >= 0 && data[i + 1] <= 0 || data[i] <= 0 && data[i + 1] >= 0) { - const dist = Math.abs(i - targetSample); - if (dist < minDistance) { - minDistance = dist; - bestSample = i; - } - } - } - return bestSample / sampleRate; -}; -const VolumeKnob = ({ - value, - onChange, - min = 0, - max = 1 -}) => { - const [isDragging, setIsDragging] = useState(false); - const startY = useRef(0); - const startValue = useRef(0); - const rotation = useMemo(() => { - const percent = (value - min) / (max - min); - return -135 + percent * 270; - }, [value, min, max]); - const handleMouseDown = e => { - setIsDragging(true); - startY.current = e.clientY; - startValue.current = value; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - const handleMouseMove = e => { - const deltaY = startY.current - e.clientY; - const sensitivity = 0.005; - const newValue = Math.max(min, Math.min(max, startValue.current + deltaY * sensitivity)); - onChange(parseFloat(newValue.toFixed(2))); - }; - const handleMouseUp = () => { - setIsDragging(false); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - return /*#__PURE__*/React.createElement("div", { - className: "knob-container cursor-ns-resize flex flex-col items-center", - onMouseDown: handleMouseDown, - title: `Volume: ${Math.round(value * 100)}%` - }, /*#__PURE__*/React.createElement("svg", { - className: "w-7 h-7", - viewBox: "0 0 40 40" - }, /*#__PURE__*/React.createElement("circle", { - cx: "20", - cy: "20", - r: "16", - fill: "#141414", - stroke: "#444", - strokeWidth: "2" - }), /*#__PURE__*/React.createElement("g", { - transform: `rotate(${rotation} 20 20)`, - className: "knob-dial" - }, /*#__PURE__*/React.createElement("line", { - x1: "20", - y1: "20", - x2: "20", - y2: "6", - stroke: "#ef4444", - strokeWidth: "3", - strokeLinecap: "round" - })))); -}; -const WaveformLane = ({ - track, - zoom, - timelineWidth, - viewportWidth, - onSelectRange, - onPlayheadSet, - isSelected, - onSelectTrack, - markers, - selectionMode, - localSelectionTrackId, - localSelectionStart, - currentTime, - getLocalAnchor, - onClearLocalSelection, - onSetSelectionMode, - onSetSelectionStart, - onSetSelectionEnd, - onSetCurrentTime, - onSetLocalSelectionTrackId, - onSetLocalSelectionStart, - onSetLocalSelectionEnd, - localSelLeft, - localSelRight, - onTrackLaneMouseDown, - onContextMenu, - onClipDragStart, - onClipStretchStart, - onSectionItemDragStart, - onSectionItemResizeStart, - onEditSectionInTab, - onSelectionEdgeDragStart, - setSelectedClipId, - selectedClipId, - activeTool, - onSplitTrackAtTime, - onEditClipInSubTab, - snapValue, - bpm, - scrollLeft -}) => { - const canvasRef = useRef(null); - const drawWidth = Math.min(timelineWidth, viewportWidth); - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const scrollLeftVal = scrollLeft || 0; - const vWidth = viewportWidth || 1200; - const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96; - canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); - canvas.height = Math.min(Math.round(height * dpr), 32768); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${height}px`; - ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d'; - ctx.fillRect(0, 0, drawWidth, height); - - // Grid lines based on Snap value - ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; - ctx.lineWidth = 1; - const tStart = scrollLeft / zoom; - const tEnd = (scrollLeft + drawWidth) / zoom; - let gridSpacing = 1.0; - if (snapValue && snapValue !== 'free') { - const beatDuration = 60 / parseFloat(bpm || 120); - let divisor = 1; - if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125; - gridSpacing = beatDuration * divisor; - } else { - gridSpacing = 60 / parseFloat(bpm || 120); - } - let drawSpacing = gridSpacing; - while (drawSpacing * zoom < 10) { - drawSpacing *= 2; - } - const firstGridStep = Math.floor(tStart / drawSpacing) * drawSpacing; - for (let s = firstGridStep; s <= tEnd; s += drawSpacing) { - const localX = (s - tStart) * zoom; - ctx.beginPath(); - ctx.moveTo(localX, 0); - ctx.lineTo(localX, height); - ctx.stroke(); - } - - // Draw waveform lane - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - if (clips.length > 0) { - clips.forEach(clip => { - const numChannels = clip.buffer.numberOfChannels || 1; - const dataL = clip.buffer.getChannelData(0); - const dataR = numChannels >= 2 ? clip.buffer.getChannelData(1) : dataL; - const sampleRate = clip.buffer.sampleRate; - const totalSamples = dataL.length; - const originalDuration = totalSamples / sampleRate; - const clipSpeed = clip.speed || 1.0; - const duration = originalDuration / clipSpeed; - const clipStartTime = clip.startTime || 0; - const clipEndTime = clipStartTime + duration; - - // Culling: Skip rendering if clip is outside visible viewport window - if (clipEndTime < tStart || clipStartTime > tEnd) return; - const xStartGlobal = clipStartTime * zoom; - const wClip = duration * zoom; - const xStartLocal = xStartGlobal - scrollLeft; - const xEndLocal = xStartLocal + wClip; - - // 1. Draw Clip Layer Background & Border - const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id; - const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier; - ctx.fillStyle = isClipSelected ? track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)' : track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)'; - ctx.strokeStyle = isClipSelected ? '#fbbf24' : track.color || '#06b6d4'; - ctx.lineWidth = isClipSelected ? 1 : 1.5; - const clipTop = 4; - const clipHeight = height - 8; - ctx.beginPath(); - if (ctx.roundRect) { - ctx.roundRect(xStartLocal, clipTop, wClip, clipHeight, 4); - } else { - ctx.rect(xStartLocal, clipTop, wClip, clipHeight); - } - ctx.fill(); - ctx.stroke(); - - // 2. Draw Clip Label & Speed Label - ctx.fillStyle = '#e4e4e7'; - ctx.font = 'bold 9px sans-serif'; - ctx.fillText(clip.name || 'Clip', Math.max(xStartLocal + 8, 8), clipTop + 12); - if (clipSpeed !== 1.0) { - ctx.fillStyle = '#fbbf24'; - ctx.font = 'bold 8px sans-serif'; - ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, Math.max(xStartLocal + 8, 8), clipTop + 22); - } - - // Draw markers - if (markers && markers.length > 0) { - markers.forEach(m => { - const mxLocal = m.time * zoom - scrollLeft; - if (mxLocal >= 0 && mxLocal <= drawWidth) { - ctx.fillStyle = '#fbbf24'; - ctx.fillRect(mxLocal - 1, 0, 2, height); - } - }); - } - - // 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match) - const drawXStartLocal = Math.max(0, Math.floor(xStartLocal)); - const drawXEndLocal = Math.min(drawWidth, Math.ceil(xEndLocal)); - const samplesPerPixel = sampleRate / zoom * clipSpeed; - const isStereo = numChannels >= 2; - const channelConfigs = isStereo ? [{ - data: dataL, - mid: height / 4, - chHeight: height / 2 - 8, - label: '1' - }, { - data: dataR, - mid: 3 * height / 4, - chHeight: height / 2 - 8, - label: '2' - }] : [{ - data: dataL, - mid: height / 2, - chHeight: clipHeight - 12, - label: 'MONO' - }]; - if (isStereo) { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(xStartLocal, height / 2); - ctx.lineTo(xStartLocal + wClip, height / 2); - ctx.stroke(); - } - channelConfigs.forEach(ch => { - const data = ch.data; - const mid = ch.mid; - const peakRatio = ch.chHeight * 0.42; - - // Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB) - const yPlus6 = mid - peakRatio * 0.8; - const yMinus6 = mid + peakRatio * 0.8; - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(xStartLocal, yPlus6); - ctx.lineTo(xStartLocal + wClip, yPlus6); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(xStartLocal, mid); - ctx.lineTo(xStartLocal + wClip, mid); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(xStartLocal, yMinus6); - ctx.lineTo(xStartLocal + wClip, yMinus6); - ctx.stroke(); - - // Decibel Text Labels (Sound Forge Monospace) - ctx.fillStyle = '#71717a'; - ctx.font = '8px monospace'; - const labelX = Math.max(xStartLocal + 4, 4); - ctx.fillText('+6.0', labelX, yPlus6 - 2); - ctx.fillText('-Inf', labelX, mid - 2); - ctx.fillText('-6.0', labelX, yMinus6 + 8); - - // Sound Forge Channel ID Badge (1 or 2) - if (isStereo) { - ctx.fillStyle = 'rgba(6, 182, 212, 0.85)'; - ctx.font = 'bold 9px monospace'; - ctx.fillText(ch.label, Math.min(xStartLocal + wClip - 12, drawWidth - 16), mid - ch.chHeight * 0.35); - } - ctx.strokeStyle = '#5bc0be'; // Cornflower Blue - ctx.lineWidth = 1.2; - if (samplesPerPixel < 4) { - // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) - const visibleTStart = (drawXStartLocal - xStartLocal) / zoom; - const visibleTEnd = (drawXEndLocal - xStartLocal) / zoom; - const startSample = Math.max(0, Math.floor(visibleTStart * clipSpeed * sampleRate)); - const endSample = Math.min(totalSamples, Math.ceil(visibleTEnd * clipSpeed * sampleRate)); - ctx.beginPath(); - let first = true; - const maxSamples = 50000; - const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); - for (let i = startSample; i < endSample; i += vectorStep) { - const sampleTime = i / sampleRate / clipSpeed; - const pxLocal = xStartLocal + sampleTime * zoom; - const y = mid - data[i] * peakRatio; - if (first) { - ctx.moveTo(pxLocal, y); - first = false; - } else { - ctx.lineTo(pxLocal, y); - } - } - ctx.stroke(); - - // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) - if (samplesPerPixel < 0.3) { - const maxNodes = 5000; - const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); - ctx.fillStyle = '#6ee7b7'; - let drawn = 0; - for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { - const sampleTime = i / sampleRate / clipSpeed; - const pxLocal = xStartLocal + sampleTime * zoom; - const y = mid - data[i] * peakRatio; - ctx.fillRect(pxLocal - 1, y - 1, 2, 2); - drawn++; - } - } - } else { - // Coarse view vertical peak min/max bars per pixel - for (let pxLocal = drawXStartLocal; pxLocal < drawXEndLocal; pxLocal++) { - const timeInClip = (pxLocal - xStartLocal) / zoom * clipSpeed; - const sampleIdx = Math.floor(timeInClip * sampleRate); - const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); - const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); - const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize); - let maxVal = 0; - for (let i = chunkStart; i < chunkEnd; i++) { - const abs = Math.abs(data[i]); - if (abs > maxVal) maxVal = abs; - } - const peakHeight = maxVal * peakRatio; - ctx.beginPath(); - ctx.moveTo(pxLocal, mid - peakHeight); - ctx.lineTo(pxLocal, mid + peakHeight); - ctx.stroke(); - } - } - }); - }); - } else { - ctx.fillStyle = '#444'; - ctx.font = '12px Inter, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2); - } - - // Draw sections - const sections = track.sections || []; - sections.forEach(sec => { - const secStartLocal = sec.start * zoom - scrollLeft; - const secWidth = sec.duration * zoom; - if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return; - ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)'; - ctx.fillRect(secStartLocal, 2, secWidth, height - 4); - ctx.strokeStyle = sec.color || '#06b6d4'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.strokeRect(secStartLocal, 2, secWidth, height - 4); - ctx.setLineDash([]); - ctx.fillStyle = '#e4e4e7'; - ctx.font = 'bold 9px sans-serif'; - ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14); - }); - - // Draw MIDI items - const midiItems = track.midiItems || []; - midiItems.forEach(midi => { - const midiStartLocal = midi.startTime * zoom - scrollLeft; - const midiWidth = midi.duration * zoom; - if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return; - ctx.fillStyle = '#a78bfa33'; - ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4); - ctx.strokeStyle = '#a78bfa'; - ctx.lineWidth = 1.5; - ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4); - ctx.fillStyle = '#c4b5fd'; - ctx.font = 'bold 9px sans-serif'; - ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14); - }); - - // Selection highlight - local selection on this track - if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) { - const hlLeftLocal = localSelLeft * zoom - scrollLeft; - const hlWidth = (localSelRight - localSelLeft) * zoom; - ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; - ctx.fillRect(hlLeftLocal, 0, hlWidth, height); - ctx.strokeStyle = '#f59e0b'; - ctx.lineWidth = 1; - ctx.strokeRect(hlLeftLocal, 0, hlWidth, height); - } - }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft]); - return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { - key: "virtual-spacer", - style: { - width: `${timelineWidth}px`, - height: '1px', - pointerEvents: 'none' - } - }), /*#__PURE__*/React.createElement("canvas", { - ref: canvasRef, - style: { - position: 'sticky', - left: 0, - imageRendering: 'pixelated' - }, - className: "cursor-crosshair", - onMouseMove: e => { - if (!canvasRef.current) return; - const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + (scrollLeft || 0); - const time = x / zoom; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - - // 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform - if (e.shiftKey && e.buttons > 0) { - e.preventDefault(); - e.stopPropagation(); - const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; - const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime; - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - if (onSetSelectionMode) onSetSelectionMode('local'); - if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); - if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); - if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); - if (onSetSelectionStart) onSetSelectionStart(selS); - if (onSetSelectionEnd) onSetSelectionEnd(selE); - if (onSetCurrentTime) onSetCurrentTime(time); - if (onSelectTrack) onSelectTrack(track.id); - return; - } - - // Check if hovering near local selection boundaries of this track - const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; - if (isLocal && localSelLeft !== null && localSelRight !== null) { - const leftPx = localSelLeft * zoom; - const rightPx = localSelRight * zoom; - const distToLeft = Math.abs(x - leftPx); - const distToRight = Math.abs(x - rightPx); - if (distToLeft <= 5 || distToRight <= 5) { - canvasRef.current.style.cursor = 'ew-resize'; - return; - } - } - - // Check if hovering near right edge of a clip for time-stretching (Alt key required) - const toleranceSec = 8 / zoom; - const rightEdgeClip = clips.find(c => { - const duration = c.buffer.duration / (c.speed || 1.0); - return Math.abs(time - (c.startTime + duration)) <= toleranceSec; - }); - if (rightEdgeClip && e.altKey) { - canvasRef.current.style.cursor = 'ew-resize'; - return; - } - - // Check section/MIDI item hover for resize or drag - const allSections = track.sections || []; - const allMidiItems = track.midiItems || []; - const sectionTolerance = 8 / zoom; - let foundSectionItem = null; - let sectionItemEdge = null; - const checkEdge = (item, startTime, dur) => { - const leftEdge = Math.abs(time - startTime) <= sectionTolerance; - const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance; - if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right'; - return null; - }; - for (const sec of allSections) { - const edge = checkEdge(sec, sec.start, sec.duration); - if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; } - } - if (!foundSectionItem) { - for (const midi of allMidiItems) { - const edge = checkEdge(midi, midi.startTime, midi.duration); - if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; } - } - } - if (foundSectionItem && sectionItemEdge) { - canvasRef.current.style.cursor = 'ew-resize'; - return; - } - // Check body hover for drag - if (!foundSectionItem) { - for (const sec of allSections) { - if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; } - } - } - if (!foundSectionItem) { - for (const midi of allMidiItems) { - if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; } - } - } - if (foundSectionItem) { - canvasRef.current.style.cursor = 'grab'; - return; - } - - const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - const isOverClip = !!hoveredClip; - if (activeTool === 'pen') { - canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed'; - } else if (activeTool === 'grab') { - canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default'; - } else if (activeTool === 'razor') { - canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed'; - } else { - // select tool - canvasRef.current.style.cursor = isOverClip && (e.altKey || e.ctrlKey) ? 'grab' : 'crosshair'; - } - }, - onMouseDown: e => { - // Ignore right-click for local selection drag (context menu handles it) - if (e.button === 2) return; - const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + (scrollLeft || 0); - const time = Math.max(0, x / zoom); - onSelectTrack(track.id); - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - - // Check if Ctrl+Click to exit selection - if (e.ctrlKey && selectionMode) { - e.preventDefault(); - e.stopPropagation(); - if (onClearLocalSelection) onClearLocalSelection(); - if (onSetSelectionMode) onSetSelectionMode(null); - if (onSetSelectionStart) onSetSelectionStart(null); - if (onSetSelectionEnd) onSetSelectionEnd(null); - return; - } - - // 1. Shift+Click (TOP PRIORITY): Range selection on track waveform - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; - const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime; - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - if (onSetSelectionMode) onSetSelectionMode('local'); - if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); - if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); - if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); - if (onSetSelectionStart) onSetSelectionStart(selS); - if (onSetSelectionEnd) onSetSelectionEnd(selE); - if (onSetCurrentTime) onSetCurrentTime(time); - if (onSelectTrack) onSelectTrack(track.id); - return; - } - - // Check if dragging selection boundaries (local mode) - const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; - if (isLocal && localSelLeft !== null && localSelRight !== null) { - const leftPx = localSelLeft * zoom; - const rightPx = localSelRight * zoom; - const distToLeft = Math.abs(x - leftPx); - const distToRight = Math.abs(x - rightPx); - if (distToLeft <= 5) { - e.preventDefault(); - e.stopPropagation(); - if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'left'); - return; - } else if (distToRight <= 5) { - e.preventDefault(); - e.stopPropagation(); - if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'right'); - return; - } - } - - // Check if time-stretching (Alt + Right Edge) - const toleranceSec = 8 / zoom; - const rightEdgeClip = clips.find(c => { - const duration = c.buffer.duration / (c.speed || 1.0); - return Math.abs(time - (c.startTime + duration)) <= toleranceSec; - }); - if (rightEdgeClip && e.altKey) { - e.preventDefault(); - e.stopPropagation(); - if (onClipStretchStart) { - onClipStretchStart(track.id, rightEdgeClip.id, time); - } - return; - } - - // Check section/MIDI item edge for resize, then body for drag - const secItems = track.sections || []; - const midiItemsArray = track.midiItems || []; - const secTol = 8 / zoom; - let hitItem = null; - let hitEdge = null; - for (const sec of secItems) { - if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; } - if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; } - } - if (!hitItem) { - for (const midi of midiItemsArray) { - if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; } - if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; } - } - } - if (hitItem && hitEdge) { - e.preventDefault(); - e.stopPropagation(); - if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time); - return; - } - if (!hitItem) { - for (const sec of secItems) { - if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; } - } - } - if (!hitItem) { - for (const midi of midiItemsArray) { - if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; } - } - } - if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start); - return; - } - - const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - - // Set selected clip ID - if (clickedClip) { - setSelectedClipId({ - trackId: track.id, - clipId: clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id - }); - } else { - setSelectedClipId(null); - } - if (activeTool === 'pen' && !e.ctrlKey) { - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onEditClipInSubTab) { - onEditClipInSubTab(track.id, clickedClip.id); - } - } - return; - } - if (activeTool === 'razor' && !e.ctrlKey) { - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onSplitTrackAtTime) { - onSplitTrackAtTime(track.id, clickedClip.id, time); - } - } - return; - } - if (activeTool === 'grab') { - if (onTrackLaneMouseDown) { - onTrackLaneMouseDown(track.id, time, e); - } - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onClipDragStart) { - onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); - } - } else { - onPlayheadSet(time); - } - return; - } - - // Check for click drag clip (Alt to move, Ctrl to duplicate) - if (clickedClip && (e.altKey || e.ctrlKey)) { - e.preventDefault(); - e.stopPropagation(); - if (onClipDragStart) { - onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); - } - return; - } - - // Check if Ctrl+Click outside active local selection to clear - if (e.ctrlKey) { - if (onClearLocalSelection) onClearLocalSelection(); - if (onSetSelectionMode) onSetSelectionMode(null); - if (onSetSelectionStart) onSetSelectionStart(null); - if (onSetSelectionEnd) onSetSelectionEnd(null); - return; - } - onPlayheadSet(time); - if (onTrackLaneMouseDown) { - onTrackLaneMouseDown(track.id, time, e); - } - e.stopPropagation(); - }, - onDoubleClick: e => { - const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + (scrollLeft || 0); - const time = Math.max(0, x / zoom); - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - // Check double-click on section first - const dblSecItems = track.sections || []; - let dblSecHit = null; - for (const sec of dblSecItems) { - if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; } - } - if (dblSecHit) { - e.preventDefault(); - e.stopPropagation(); - if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id); - return; - } - const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onEditClipInSubTab) { - onEditClipInSubTab(track.id, clickedClip.id); - } - } - }, - onContextMenu: e => { - e.preventDefault(); - e.stopPropagation(); - onSelectTrack(track.id); - const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + (scrollLeft || 0); - const time = Math.max(0, x / zoom); - const secList = track.sections || []; - let hitSectionId = null; - for (const sec of secList) { - if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; } - } - if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId); - } - })); -}; -const TempoTrackLane = ({ - bpm, - zoom, - timelineWidth, - viewportWidth, - onPlayheadSet, - snapValue, - onRulerMouseDown, - scrollLeft -}) => { - const canvasRef = useRef(null); - const drawWidth = Math.min(timelineWidth, viewportWidth); - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const scrollLeftVal = scrollLeft || 0; - const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40; - canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); - canvas.height = Math.min(Math.round(height * dpr), 32768); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${height}px`; - ctx.fillStyle = '#1a1a2e'; - ctx.fillRect(0, 0, drawWidth, height); - const beatDuration = 60 / bpm; - const barDuration = beatDuration * 4; - const tStart = scrollLeft / zoom; - const tEnd = (scrollLeft + drawWidth) / zoom; - const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; - for (let t = firstBeat; t <= tEnd; t += beatDuration) { - const beatNum = Math.floor(t / beatDuration) + 1; - const isBar = beatNum % 4 === 1; - const localX = (t - tStart) * zoom; - if (isBar) { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(localX, 0); - ctx.lineTo(localX, height); - ctx.stroke(); - ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.font = 'bold 9px Inter, sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11); - } else if (zoom >= 2) { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(localX, 0); - ctx.lineTo(localX, height); - ctx.stroke(); - if (zoom >= 5) { - ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; - ctx.font = '8px Inter, sans-serif'; - ctx.textAlign = 'left'; - const bar = Math.ceil(beatNum / 4); - const beat = (beatNum - 1) % 4 + 1; - ctx.fillText(`${bar}:${beat}`, localX + 2, 9); - } - } else { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(localX, 0); - ctx.lineTo(localX, height); - ctx.stroke(); - } - } - - // Draw snap sub-ticks at the bottom - if (snapValue && snapValue !== 'free') { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; - ctx.lineWidth = 0.8; - let divisor = 1; - if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125; - const snapInterval = beatDuration * divisor; - if (snapInterval * zoom >= 4) { - const firstSnap = Math.floor(tStart / snapInterval) * snapInterval; - for (let t = firstSnap; t <= tEnd; t += snapInterval) { - const onBeat = Math.abs(t / beatDuration - Math.round(t / beatDuration)) < 0.001; - if (!onBeat) { - const localX = (t - tStart) * zoom; - ctx.beginPath(); - ctx.moveTo(localX, height - 6); - ctx.lineTo(localX, height); - ctx.stroke(); - } - } - } - } - ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; - ctx.font = 'bold 10px Inter, sans-serif'; - ctx.textAlign = 'right'; - ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12); - }, [bpm, zoom, timelineWidth, viewportWidth, snapValue, scrollLeft]); - return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { - key: "virtual-spacer-tempo", - style: { - width: `${timelineWidth}px`, - height: '1px', - pointerEvents: 'none' - } - }), /*#__PURE__*/React.createElement("canvas", { - ref: canvasRef, - style: { - position: 'sticky', - left: 0, - imageRendering: 'pixelated' - }, - className: "cursor-crosshair", - onMouseDown: e => { - const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + (scrollLeft || 0); - const time = Math.max(0, x / zoom); - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - } - if (onRulerMouseDown) { - onRulerMouseDown(e); - } else { - onPlayheadSet(time, e.shiftKey); - } - } - })); -}; - -// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── -const SubTabWaveform = ({ - buffer, - subTabId, - activeTab, - currentTime, - selectionStart, - selectionEnd, - onSelectRange, - onPlayheadSet, - onContextMenu, - activeTool, - zoom, - timelineWidth, - color, - name, - speed = 1.0, - onSpeedChange, - volumeNodes = [], - panningNodes = [], - fadeInLen = 0, - fadeOutLen = 0, - graphMode = null, - onUpdateNodes, - onUpdateFade, - onModeToggle, - selectedNodeTime, - setSelectedNodeTime, - channelInfo = null -}) => { - const canvasRef = useRef(null); - const isStretchingRef = useRef(false); - const stretchStartRef = useRef({ - mouseX: 0, - originalDuration: 0, - originalSpeed: 1.0 - }); - const subTabAnchorRef = useRef(null); - const isStereo = channelInfo ? channelInfo.isStereo : buffer && buffer.numberOfChannels >= 2; - const channelLabel = channelInfo ? channelInfo.label : isStereo ? 'STEREO' : 'MONO'; - // Mono: force volume mode (panning not applicable) - const effectiveGraphMode = !isStereo && graphMode === 'pan' ? null : graphMode; - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = wrapper ? wrapper.clientWidth : 1200; - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); - const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; - canvas.width = Math.round(drawWidth * dpr); - canvas.height = Math.round(h * dpr); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${h}px`; - ctx.fillStyle = '#181818'; - ctx.fillRect(0, 0, drawWidth, h); - const data = buffer.getChannelData(0); - const len = data.length; - if (len === 0) return; - - // Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline - const computeHermiteTangents = pts => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i - 1].time; - const hN = pts[i + 1].time - pts[i].time; - const sP = (pts[i].db - pts[i - 1].db) / hP; - const sN = (pts[i + 1].db - pts[i].db) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; - m[n - 1] = n > 1 ? (pts[n - 1].db - pts[n - 2].db) / (pts[n - 1].time - pts[n - 2].time) : 0; - return m; - }; - const getVolumeDbAtTime = t => { - const volNodes = volumeNodes || []; - if (volNodes.length === 0) return 0.0; - const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); - if (sortedNodes.length === 1) return sortedNodes[0].db; - if (t <= sortedNodes[0].time) return sortedNodes[0].db; - if (t >= sortedNodes[sortedNodes.length - 1].time) return sortedNodes[sortedNodes.length - 1].db; - const tangents = computeHermiteTangents(sortedNodes); - for (let i = 0; i < sortedNodes.length - 1; i++) { - const n1 = sortedNodes[i]; - const n2 = sortedNodes[i + 1]; - if (t >= n1.time && t <= n2.time) { - const h = n2.time - n1.time; - if (h <= 0) return n1.db; - const frac = (t - n1.time) / h; - const frac2 = frac * frac, - frac3 = frac2 * frac; - return (2 * frac3 - 3 * frac2 + 1) * n1.db + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.db + (frac3 - frac2) * h * tangents[i + 1]; - } - } - return 0.0; - }; - const getVolumeGainAtTime = t => { - return Math.pow(10, getVolumeDbAtTime(t) / 20); - }; - const computeHermiteTangentsForPan = pts => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i - 1].time; - const hN = pts[i + 1].time - pts[i].time; - const sP = (pts[i].pan - pts[i - 1].pan) / hP; - const sN = (pts[i + 1].pan - pts[i].pan) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (pts[1].pan - pts[0].pan) / (pts[1].time - pts[0].time) : 0; - m[n - 1] = n > 1 ? (pts[n - 1].pan - pts[n - 2].pan) / (pts[n - 1].time - pts[n - 2].time) : 0; - return m; - }; - const getPanningValueAtTime = t => { - const panNodes = panningNodes || []; - if (panNodes.length === 0) return 0; - const sortedNodes = [...panNodes].sort((a, b) => a.time - b.time); - if (sortedNodes.length === 1) return Math.round(sortedNodes[0].pan * 100); - if (t <= sortedNodes[0].time) return Math.round(sortedNodes[0].pan * 100); - if (t >= sortedNodes[sortedNodes.length - 1].time) return Math.round(sortedNodes[sortedNodes.length - 1].pan * 100); - const tangents = computeHermiteTangentsForPan(sortedNodes); - for (let i = 0; i < sortedNodes.length - 1; i++) { - const n1 = sortedNodes[i]; - const n2 = sortedNodes[i + 1]; - if (t >= n1.time && t <= n2.time) { - const h = n2.time - n1.time; - if (h <= 0) return Math.round(n1.pan * 100); - const frac = (t - n1.time) / h; - const frac2 = frac * frac, - frac3 = frac2 * frac; - const val = (2 * frac3 - 3 * frac2 + 1) * n1.pan + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.pan + (frac3 - frac2) * h * tangents[i + 1]; - return Math.round(val * 100); - } - } - return 0; - }; - - // Draw clip container (like main session clips) - const xStart = 0; - const wClip = buffer.duration / speed * zoom; // speed-adjusted width - const xEnd = xStart + wClip; - const clipTop = 8; - const clipHeight = h - 16; - const clipColor = color || '#06b6d4'; - ctx.fillStyle = clipColor + '22'; - ctx.strokeStyle = clipColor; - ctx.lineWidth = 1.5; - if (ctx.roundRect) { - ctx.beginPath(); - ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4); - ctx.fill(); - ctx.stroke(); - } else { - ctx.fillRect(xStart, clipTop, wClip, clipHeight); - ctx.strokeRect(xStart, clipTop, wClip, clipHeight); - } - - // Draw clip label with speed % - ctx.fillStyle = '#e4e4e7'; - ctx.font = 'bold 10px sans-serif'; - let displayName = name || 'Audio Clip'; - if (speed !== 1.0) { - displayName += ` (${Math.round(speed * 100)}%)`; - } - ctx.fillText(displayName, xStart + 8, clipTop + 14); - - // ── Graph Grid & Axes ── - const drawGrid = true; - if (drawGrid) { - // Background fill - ctx.fillStyle = '#181818'; - ctx.fillRect(xStart, clipTop, wClip, clipHeight); - - // Vertical grid lines (time markers) - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 0.5; - ctx.setLineDash([]); - const timeStep = Math.max(0.1, Math.ceil(buffer.duration / 20 * 10) / 10); - for (let t = 0; t <= buffer.duration; t += timeStep) { - const px = t * zoom; - ctx.beginPath(); - ctx.moveTo(px, clipTop); - ctx.lineTo(px, clipTop + clipHeight); - ctx.stroke(); - } - - // Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown) - const volZeroY = clipTop + 1 / 3 * clipHeight; - const panZeroY = clipTop + 1 / 2 * clipHeight; - - // White line for volume 0dB - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 1.2; - ctx.beginPath(); - ctx.moveTo(xStart, volZeroY); - ctx.lineTo(xStart + wClip, volZeroY); - ctx.stroke(); - - // Brown line for panning center - ctx.strokeStyle = '#854d0e'; - ctx.lineWidth = 1.2; - ctx.beginPath(); - ctx.moveTo(xStart, panZeroY); - ctx.lineTo(xStart + wClip, panZeroY); - ctx.stroke(); - - // Horizontal grid lines (other value markers) - const isPanMode = effectiveGraphMode === 'pan'; - if (isPanMode) { - for (let p = -100; p <= 100; p += 20) { - if (p === 0) continue; - const y = clipTop + clipHeight * (1 - (p / 100 + 1) / 2); - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(xStart, y); - ctx.lineTo(xStart + wClip, y); - ctx.stroke(); - } - } else { - for (let db = -30; db <= 3; db += 3) { - if (db === 0) continue; - const y = db >= 0 ? volZeroY - db / 3 * (2 / 3 * clipHeight) : volZeroY + -db / 30 * (1 / 3 * clipHeight); - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(xStart, y); - ctx.lineTo(xStart + wClip, y); - ctx.stroke(); - } - } - - // Y-axis labels (left side) - ctx.fillStyle = '#71717a'; - ctx.font = '7px monospace'; - ctx.textAlign = 'right'; - if (isPanMode) { - ctx.fillText('L100', xStart - 2, clipTop + 8); - ctx.fillText('R50', xStart - 2, clipTop + clipHeight * 0.25 + 2); - ctx.fillStyle = '#854d0e'; // Brown label for active Panning Center - ctx.fillText('C (Pan)', xStart - 2, clipTop + clipHeight * 0.5 + 2); - ctx.fillStyle = '#71717a'; - ctx.fillText('L50', xStart - 2, clipTop + clipHeight * 0.75 + 2); - ctx.fillText('R100', xStart - 2, clipTop + clipHeight - 2); - } else { - ctx.fillText('+3dB', xStart - 2, clipTop + 8); - ctx.fillStyle = '#ffffff'; // White label for active Volume 0dB - ctx.fillText('0dB (Vol)', xStart - 2, volZeroY + 2); - ctx.fillStyle = '#71717a'; - ctx.fillText('-15dB', xStart - 2, volZeroY + clipHeight / 6 + 2); - ctx.fillText('-30dB', xStart - 2, clipTop + clipHeight - 2); - } - ctx.textAlign = 'start'; - } - - // Drag hint labels for fade endpoints - ctx.fillStyle = '#71717a'; - ctx.font = '8px sans-serif'; - ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12); - - // Display speed percentage in the bottom left corner of the waveform area - ctx.fillStyle = '#e4e4e7'; - ctx.font = 'bold 9px sans-serif'; - ctx.fillText(`Tốc độ: ${Math.round(speed * 100)}%`, xStart + 8, clipTop + clipHeight - 8); - - // Mode toggle button on waveform (bottom-right) - const modeBtnW = 28; - const modeBtnH = 14; - const modeBtnX = wClip - modeBtnW - 4; - const modeBtnY = clipTop + clipHeight - modeBtnH - 2; - const isPanMode = effectiveGraphMode === 'pan'; - ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)'; - ctx.beginPath(); - ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3); - ctx.fill(); - ctx.fillStyle = '#fff'; - ctx.font = 'bold 7px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10); - ctx.textAlign = 'start'; - - // Channel label (L / R for stereo, M for mono) - ctx.fillStyle = '#a1a1aa'; - ctx.font = 'bold 8px monospace'; - if (isStereo) { - ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28); - ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72); - } else { - ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2); - } - - // Draw waveform inside clip (speed-adjusted) with fade envelope applied - const drawXStart = Math.max(0, Math.floor(xStart)); - const drawXEnd = Math.min(drawWidth, Math.ceil(xEnd)); - const samplesPerPixel = buffer.sampleRate / zoom * speed; - const bufDur = buffer.duration; - const mid = h / 2; - const peakRatio = clipHeight * 0.45; - const getFadeGainAtTime = t => { - if (fadeInLen > 0 && t < fadeInLen) { - return (1 - Math.cos(Math.PI * t / fadeInLen)) / 2; - } else if (fadeOutLen > 0 && t > bufDur - fadeOutLen) { - const ratio = (t - (bufDur - fadeOutLen)) / fadeOutLen; - return (1 + Math.cos(Math.PI * ratio)) / 2; - } - return 1; - }; - ctx.strokeStyle = '#5bc0be'; - ctx.lineWidth = 1.2; - if (samplesPerPixel < 4) { - // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) - const startSample = Math.max(0, Math.floor((drawXStart - xStart) / zoom * speed * buffer.sampleRate)); - const endSample = Math.min(len, Math.ceil((drawXEnd - xStart) / zoom * speed * buffer.sampleRate)); - ctx.beginPath(); - let first = true; - const maxSamples = 50000; - const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); - for (let i = startSample; i < endSample; i += vectorStep) { - const timeInClip = i / buffer.sampleRate / speed; - const px = xStart + timeInClip * zoom; - const fadeGain = getFadeGainAtTime(timeInClip); - const volGain = getVolumeGainAtTime(px / zoom); - const totalGain = fadeGain * volGain; - const y = mid - data[i] * totalGain * peakRatio; - if (first) { - ctx.moveTo(px, y); - first = false; - } else { - ctx.lineTo(px, y); - } - } - ctx.stroke(); - - // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) - if (samplesPerPixel < 0.3) { - const maxNodes = 5000; - const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); - ctx.fillStyle = '#6ee7b7'; - let drawn = 0; - for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { - const timeInClip = i / buffer.sampleRate / speed; - const px = xStart + timeInClip * zoom; - const fadeGain = getFadeGainAtTime(timeInClip); - const volGain = getVolumeGainAtTime(px / zoom); - const totalGain = fadeGain * volGain; - const y = mid - data[i] * totalGain * peakRatio; - ctx.fillRect(px - 1, y - 1, 2, 2); - drawn++; - } - } - } else { - // Coarse view vertical peak min/max bars - for (let px = drawXStart; px < drawXEnd; px++) { - const timeInClip = px / zoom * speed; - const sampleIdx = Math.floor(timeInClip * buffer.sampleRate); - const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); - const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); - const chunkEnd = Math.min(len, chunkStart + chunkSize); - let fadeGain = getFadeGainAtTime(timeInClip); - const volGain = getVolumeGainAtTime(px / zoom); - const totalGain = fadeGain * volGain; - let maxVal = 0; - let minVal = 0; - for (let i = chunkStart; i < chunkEnd; i++) { - const val = data[i]; - if (val > maxVal) maxVal = val; - if (val < minVal) minVal = val; - } - maxVal *= totalGain; - minVal *= totalGain; - const yTop = mid + minVal * peakRatio; - const yBottom = mid + maxVal * peakRatio; - ctx.beginPath(); - ctx.moveTo(px, yTop); - ctx.lineTo(px, yBottom); - ctx.stroke(); - } - } - - // Volume: 0dB at 1/3 from top - const autoY = node => { - const db = typeof node === 'number' ? node : node.db; - const zeroY = clipTop + 1 / 3 * clipHeight; - return db >= 0 ? zeroY - db / 3 * (1 / 3 * clipHeight) : zeroY + -db / 30 * (2 / 3 * clipHeight); - }; - // Panning: 0 at center - const autoPanY = node => { - const pan = typeof node === 'number' ? node : node.pan; - return clipTop + (1 - (pan + 1) / 2) * clipHeight; - }; - - // Helper: compute tangents for monotone Hermite spline - const computeTangents = (pts, yFn) => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i - 1].time; - const hN = pts[i + 1].time - pts[i].time; - const sP = (yFn(pts[i]) - yFn(pts[i - 1])) / hP; - const sN = (yFn(pts[i + 1]) - yFn(pts[i])) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (yFn(pts[1]) - yFn(pts[0])) / (pts[1].time - pts[0].time) : 0; - m[n - 1] = n > 1 ? (yFn(pts[n - 1]) - yFn(pts[n - 2])) / (pts[n - 1].time - pts[n - 2].time) : 0; - return m; - }; - - // Helper: evaluate Hermite at pixel position px - const hermiteY = (px, x0, y0, m0, x1, y1, m1) => { - const h = x1 - x0; - if (h <= 0) return y0; - const t = (px - x0) / h; - const t2 = t * t, - t3 = t2 * t; - return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * m1; - }; - - // Draw automation curve with Hermite spline - const drawSpline = (nodes, yFn, color, lineDash) => { - if (nodes.length < 2) { - if (nodes.length === 1) { - const px = nodes[0].time * zoom; - const y = yFn(nodes[0]); - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(px, y, 4, 0, Math.PI * 2); - ctx.fill(); - } - return; - } - const tangents = computeTangents(nodes, yFn); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.setLineDash(lineDash || []); - ctx.beginPath(); - for (let i = 0; i < nodes.length - 1; i++) { - const x0 = nodes[i].time * zoom, - y0 = yFn(nodes[i]); - const x1 = nodes[i + 1].time * zoom, - y1 = yFn(nodes[i + 1]); - const t0 = nodes[i].time, - t1 = nodes[i + 1].time; - const m0 = tangents[i], - m1 = tangents[i + 1]; - for (let px = Math.floor(x0); px < Math.ceil(x1); px++) { - const t = px / zoom; - const y = hermiteY(t, t0, y0, m0, t1, y1, m1); - if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y);else ctx.lineTo(px, y); - } - } - ctx.stroke(); - ctx.setLineDash([]); - - // Draw node handles - nodes.forEach(n => { - const px = n.time * zoom, - y = yFn(n); - const isSelected = n.time === selectedNodeTime; - ctx.fillStyle = isSelected ? '#ffebb3' : '#fff'; - ctx.beginPath(); - ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = isSelected ? '#fbbf24' : color; - ctx.lineWidth = isSelected ? 2.5 : 1.5; - ctx.beginPath(); - ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); - ctx.stroke(); - - // Display value at node - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 12px sans-serif'; - const labelText = isPanMode ? n.pan > 0 ? 'R' + Math.round(n.pan * 100) : n.pan < 0 ? 'L' + Math.round(Math.abs(n.pan) * 100) : 'C' : `${n.db >= 0 ? '+' : ''}${n.db.toFixed(1)}dB`; - ctx.fillText(labelText, px + 8, y + 4); - }); - }; - drawSpline(volumeNodes, autoY, '#f43f5e'); - drawSpline(panningNodes, autoPanY, '#a855f7', [4, 4]); - - // ── Fade Curves (transparent, only curve lines + endpoint handles) ── - const FADE_COLOR = '#b91c1c'; - const HANDLE_RADIUS = 5; - if (fadeInLen >= 0) { - const fiPx = fadeInLen * zoom; - if (fadeInLen > 0) { - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.8; - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(0, clipTop + clipHeight); - for (let px = 0; px <= fiPx; px++) { - const ratio = px / fiPx; - const amp = (1 - Math.cos(Math.PI * ratio)) / 2; - const y = clipTop + clipHeight - amp * clipHeight; - ctx.lineTo(px, y); - } - ctx.stroke(); - } - - // Endpoint handle (draggable) - const endX = fiPx, - endY = clipTop; - ctx.fillStyle = '#fff'; - ctx.beginPath(); - ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); - ctx.stroke(); - // Label - ctx.fillStyle = '#b91c1c'; - ctx.font = 'bold 7px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('FI', endX, endY - HANDLE_RADIUS - 3); - ctx.textAlign = 'start'; - } - if (fadeOutLen >= 0) { - const foPx = fadeOutLen * zoom; - const startX = wClip - foPx; - if (fadeOutLen > 0) { - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.8; - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(startX, clipTop); - for (let px = 0; px <= foPx; px++) { - const ratio = px / foPx; - const amp = (1 + Math.cos(Math.PI * ratio)) / 2; - const y = clipTop + clipHeight - amp * clipHeight; - ctx.lineTo(startX + px, y); - } - ctx.stroke(); - } - - // Endpoint handle (draggable) - const handleX = startX, - handleY = clipTop; - ctx.fillStyle = '#fff'; - ctx.beginPath(); - ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); - ctx.stroke(); - ctx.fillStyle = '#b91c1c'; - ctx.font = 'bold 7px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('FO', handleX, handleY - HANDLE_RADIUS - 3); - ctx.textAlign = 'start'; - } - - // Draw right-edge stretch handle indicator - if (wClip > 0 && wClip < drawWidth) { - ctx.strokeStyle = clipColor; - ctx.lineWidth = 1.5; - ctx.setLineDash([3, 3]); - ctx.beginPath(); - ctx.moveTo(wClip + xStart, clipTop); - ctx.lineTo(wClip + xStart, clipTop + clipHeight); - ctx.stroke(); - ctx.setLineDash([]); - } - - // Highlight selection if active - if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) { - const left = Math.min(selectionStart, selectionEnd); - const right = Math.max(selectionStart, selectionEnd); - const leftPx = left * zoom; - const rightPx = right * zoom; - ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; - ctx.fillRect(leftPx, 0, rightPx - leftPx, h); - ctx.strokeStyle = '#f59e0b'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(leftPx, 0); - ctx.lineTo(leftPx, h); - ctx.moveTo(rightPx, 0); - ctx.lineTo(rightPx, h); - ctx.stroke(); - } - - // Draw playhead - if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration / speed) { - const playheadPx = currentTime * zoom; - ctx.strokeStyle = '#ef4444'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(playheadPx, 0); - ctx.lineTo(playheadPx, h); - ctx.stroke(); - } - - // Update TCP slider values in real-time to match the curve at currentTime - const volInput = document.getElementById(`tcp-vol-${subTabId}`); - const volLabel = document.getElementById(`tcp-vol-label-${subTabId}`); - if (volInput && volLabel) { - const dbVal = getVolumeDbAtTime(currentTime || 0); - volInput.value = dbVal.toFixed(1); - volLabel.textContent = `${dbVal.toFixed(1)}dB`; - } - const panInput = document.getElementById(`tcp-pan-${subTabId}`); - const panLabel = document.getElementById(`tcp-pan-label-${subTabId}`); - if (panInput && panLabel) { - const panVal = getPanningValueAtTime(currentTime || 0); - panInput.value = panVal; - panLabel.textContent = panVal > 0 ? 'R' : panVal < 0 ? 'L' : 'C'; - const panLabelDetailed = document.getElementById(`tcp-pan-label-detailed-${subTabId}`); - if (panLabelDetailed) { - panLabelDetailed.textContent = panVal > 0 ? 'R' + panVal : panVal < 0 ? 'L' + Math.abs(panVal) : 'C'; - } - } - }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime, subTabId]); - const handleMouseDown = e => { - if (e.button === 2) return; - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const parent = canvas.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const bufDuration = buffer.duration; - const wallDuration = bufDuration / (speed || 1.0); - const mouseX = e.clientX - rect.left + scrollLeft; - const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom)); - - // Shift + Click range selection in SubTab Waveform - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - const anchor = subTabAnchorRef.current !== null && subTabAnchorRef.current !== undefined ? subTabAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; - const selS = Math.min(anchor, startTime); - const selE = Math.max(anchor, startTime); - onSelectRange(selS, selE); - onPlayheadSet(startTime); - return; - } - - // Alt+Click near right edge → speed stretch - if (e.altKey && onSpeedChange) { - const clipRightEdge = bufDuration / (speed || 1.0) * zoom; - const tolerance = 8; - if (Math.abs(mouseX - clipRightEdge) <= tolerance) { - isStretchingRef.current = true; - stretchStartRef.current = { - mouseX, - originalDuration: bufDuration, - originalSpeed: speed - }; - canvas.style.cursor = 'ew-resize'; - const handleMouseMove = moveEvent => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const wClipPx = stretchStartRef.current.originalDuration / stretchStartRef.current.originalSpeed * zoom; - const deltaX = currentX - stretchStartRef.current.mouseX; - const newWClip = Math.max(10, wClipPx + deltaX); - const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom); - if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed))); - }; - const handleMouseUp = () => { - isStretchingRef.current = false; - canvas.style.cursor = 'default'; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - } - - // Mode toggle button click (bottom-right VOL/PAN) - const wClipPx = bufDuration / (speed || 1.0) * zoom; - const cTop = 8; - const cSize = 16; - const cHeight = rect.height - 16; - const modeBtnX = wClipPx - 28 - 4; - const modeBtnY = cTop + cHeight - 14 - 2; - if (mouseX >= modeBtnX && mouseX <= modeBtnX + 28 && e.clientY - rect.top >= modeBtnY && e.clientY - rect.top <= modeBtnY + 14) { - if (onModeToggle) onModeToggle(); - return; - } - - // Fade endpoint handle drag (click on FI/FO handle circles) - const HANDLE_R = 5; - const fiEndPx = fadeInLen * zoom; - const foStartPx = wClipPx - fadeOutLen * zoom; - const distToFiHandle = Math.abs(mouseX - fiEndPx) + Math.abs(e.clientY - rect.top - cTop); - const distToFoHandle = Math.abs(mouseX - foStartPx) + Math.abs(e.clientY - rect.top - cTop); - if (distToFiHandle <= HANDLE_R + 6) { - const handleMouseMove = moveEvent => { - const x = moveEvent.clientX - rect.left + scrollLeft; - const t = Math.max(0, Math.min(wallDuration, x / zoom)); - if (onUpdateFade) onUpdateFade({ - fadeInLen: t - }); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - if (distToFoHandle <= HANDLE_R + 6) { - const handleMouseMove = moveEvent => { - const x = moveEvent.clientX - rect.left + scrollLeft; - const t = Math.max(0, Math.min(wallDuration, (wClipPx - x) / zoom)); - if (onUpdateFade) onUpdateFade({ - fadeOutLen: t - }); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - - // Tool-specific behavior - if (activeTool === 'grab') { - setSelectedNodeTime(null); - onPlayheadSet(startTime); - const handleMouseMove = moveEvent => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - onPlayheadSet(ct); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - if (activeTool === 'razor') { - setSelectedNodeTime(null); - onPlayheadSet(startTime); - showToast(`Cut point at ${formatTime(startTime)}`, 'info'); - return; - } - if (activeTool === 'pen') { - // Deduplicate by time: keep last occurrence per time key - const mergeNodes = (existing, incoming) => { - const map = new Map(); - existing.forEach(n => map.set(n.time, n)); - incoming.forEach(n => map.set(n.time, n)); - return Array.from(map.values()).sort((a, b) => a.time - b.time); - }; - onPlayheadSet(startTime); - canvas.style.cursor = 'crosshair'; - const isPan = (graphMode || 'volume') === 'pan'; - const isCtrl = e.ctrlKey || e.metaKey; - const cTop = 8; - const cHeight = rect.height - 16; - const curNodes = isPan ? panningNodes : volumeNodes; - const valFromY = y => { - if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); - const yOff = (y - cTop) / cHeight; - return yOff <= 1 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3))) : Math.max(-30, Math.min(0, -30 * (yOff - 1 / 3) * (3 / 2))); - }; - const snapVal = v => isPan ? Math.round(v * 20) / 20 : Math.round(v * 2) / 2; - const snapTime = t => Math.round(t * 10) / 10; - - // Check if clicking near existing node (any mode) - const volNodeY = v => { - const z = cTop + 1 / 3 * cHeight; - return v >= 0 ? z - v / 3 * (1 / 3 * cHeight) : z + -v / 30 * (2 / 3 * cHeight); - }; - const nearNode = curNodes.findIndex(n => { - const t = Math.abs(n.time - startTime); - const val = isPan ? n.pan : n.db; - const ny = isPan ? cTop + (1 - (val + 1) / 2) * cHeight : volNodeY(val); - const dy = Math.abs(e.clientY - rect.top - ny); - return t < 0.1 / (speed || 1) && dy < 10; - }); - if (nearNode >= 0) { - // Drag existing node - setSelectedNodeTime(curNodes[nearNode].time); - let working = [...curNodes]; - let dragIdx = nearNode; - const handleMouseMove = moveEvent => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const val = snapVal(valFromY(moveEvent.clientY - rect.top)); - const updated = isPan ? { - time: Math.min(snapTime(ct), wallDuration), - pan: val - } : { - time: Math.min(snapTime(ct), wallDuration), - db: val - }; - const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); - if (onUpdateNodes) onUpdateNodes(cleaned); - working = [...cleaned]; - dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); - // Follow the selected node during dragging - setSelectedNodeTime(updated.time); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - canvas.style.cursor = 'crosshair'; - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - if (isCtrl) { - setSelectedNodeTime(null); - const pts = []; - let lastKey = ''; - const handleMouseMove = moveEvent => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const t = +snapTime(ct).toFixed(3); - const v = isPan ? +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(2) : +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(1); - const key = t + '|' + v; - if (key !== lastKey) { - pts.push({ - time: t, - [isPan ? 'pan' : 'db']: v - }); - lastKey = key; - } - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - canvas.style.cursor = 'crosshair'; - const merged = mergeNodes(curNodes, pts); - if (onUpdateNodes) onUpdateNodes(merged); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } else { - const newNode = isPan ? { - time: +snapTime(startTime).toFixed(3), - pan: +snapVal(valFromY(e.clientY - rect.top)).toFixed(2) - } : { - time: +snapTime(startTime).toFixed(3), - db: +snapVal(valFromY(e.clientY - rect.top)).toFixed(1) - }; - setSelectedNodeTime(newNode.time); - const merged = mergeNodes(curNodes, [newNode]); - if (onUpdateNodes) onUpdateNodes(merged); - - // Now drag this newly created node - let working = [...merged]; - let dragIdx = working.findIndex(n => n.time === newNode.time && (isPan ? n.pan === newNode.pan : n.db === newNode.db)); - const handleMouseMove = moveEvent => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const val = snapVal(valFromY(moveEvent.clientY - rect.top)); - const updated = isPan ? { - time: Math.min(snapTime(ct), wallDuration), - pan: val - } : { - time: Math.min(snapTime(ct), wallDuration), - db: val - }; - const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); - if (onUpdateNodes) onUpdateNodes(cleaned); - working = [...cleaned]; - dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); - setSelectedNodeTime(updated.time); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - canvas.style.cursor = 'crosshair'; - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } - return; - } - - // Select tool (default): drag to select range - subTabAnchorRef.current = startTime; - setSelectedNodeTime(null); - onSelectRange(startTime, startTime); - onPlayheadSet(startTime); - const handleMouseMove = moveEvent => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const anchor = subTabAnchorRef.current ?? startTime; - onSelectRange(Math.min(anchor, ct), Math.max(anchor, ct)); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - const handleContextMenuInternal = e => { - e.preventDefault(); - e.stopPropagation(); - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const parent = canvas.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); - onContextMenu(e, clickTime); - }; - - // Double-click on automation curve to create a new node - const handleDoubleClick = e => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - if (activeTool !== 'select' && activeTool !== 'pen') return; - const rect = canvas.getBoundingClientRect(); - const parent = canvas.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); - const isPan = (graphMode || 'volume') === 'pan'; - const curNodes = isPan ? panningNodes : volumeNodes; - const cTop = 8; - const cHeight = rect.height - 16; - const valFromY = y => { - if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); - const yOff = (y - cTop) / cHeight; - return yOff <= 2 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2))) : Math.max(-30, Math.min(0, -30 * (yOff - 2 / 3) * 3)); - }; - - // Interpolate value at click position from existing curve - let interpolatedVal = valFromY(e.clientY - rect.top); - if (curNodes.length >= 2) { - const sorted = [...curNodes].sort((a, b) => a.time - b.time); - for (let i = 0; i < sorted.length - 1; i++) { - if (clickTime >= sorted[i].time && clickTime <= sorted[i + 1].time) { - const t = (clickTime - sorted[i].time) / (sorted[i + 1].time - sorted[i].time); - const v1 = isPan ? sorted[i].pan : sorted[i].db; - const v2 = isPan ? sorted[i + 1].pan : sorted[i + 1].db; - interpolatedVal = v1 + t * (v2 - v1); - break; - } - } - } - const newNode = isPan ? { - time: +Math.round(clickTime * 10) / 10, - pan: +Math.round(interpolatedVal * 20) / 20 - } : { - time: +Math.round(clickTime * 10) / 10, - db: +Math.round(interpolatedVal * 2) / 2 - }; - const merged = (() => { - const map = new Map(); - curNodes.forEach(n => map.set(n.time, n)); - map.set(newNode.time, newNode); - return Array.from(map.values()).sort((a, b) => a.time - b.time); - })(); - if (onUpdateNodes) onUpdateNodes(merged); - }; - return /*#__PURE__*/React.createElement("div", { - style: { - width: `${timelineWidth}px`, - height: '100%', - position: 'relative', - overflow: 'hidden' - } - }, /*#__PURE__*/React.createElement("canvas", { - ref: canvasRef, - style: { - position: 'absolute', - top: 0, - left: 0, - imageRendering: 'pixelated' - }, - className: "cursor-crosshair rounded border border-zinc-800", - onMouseDown: handleMouseDown, - onDoubleClick: handleDoubleClick, - onContextMenu: handleContextMenuInternal, - onMouseMove: e => { - if (canvasRef.current && e.altKey && onSpeedChange) { - const rect = canvasRef.current.getBoundingClientRect(); - const parent = canvasRef.current.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const mx = e.clientX - rect.left + scrollLeft; - const wClip = buffer.duration / speed * zoom; - const tolerance = 8; - canvasRef.current.style.cursor = Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current ? 'ew-resize' : 'crosshair'; - } else if (canvasRef.current && !isStretchingRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - const parent = canvasRef.current.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const mx = e.clientX - rect.left + scrollLeft; - const wClipPx = buffer.duration / (speed || 1.0) * zoom; - const cH = canvasRef.current.height / (window.devicePixelRatio || 1) - 16; - const btnX = wClipPx - 28 - 4; - const btnY = 8 + cH - 14 - 2; - const overBtn = mx >= btnX && mx <= btnX + 28 && e.clientY - rect.top >= btnY && e.clientY - rect.top <= btnY + 14; - canvasRef.current.style.cursor = overBtn ? 'pointer' : 'crosshair'; - } - } - })); -}; -const SubTabToolbar = ({ - st, - activeTool, - setActiveTool, - handleSubTabNormalizeWithValue, - handleSubTabGainWithValue, - handleSubTabPitch, - handleSubTabStretch, - handleSubTabFade, - onPlayPause, - onStop, - onRewind, - onForward, - onLoop, - onRecord, - onRateChange, - onCut, - onCopy, - onPaste, - onGlue, - snapValue, - onSnapChange -}) => { - const isPlaying = st?.isPlaying || false; - const isLooping = st?.isLooping || false; - const isRecording = st?.isRecording || false; - return /*#__PURE__*/React.createElement("div", { - className: "daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" - }, /*#__PURE__*/React.createElement("button", { - className: `p-1 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`, - onClick: () => setActiveTool('select'), - title: "Select Tool" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "mouse-pointer", - className: "w-4 h-4" - }))), /*#__PURE__*/React.createElement("button", { - className: `p-1 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`, - onClick: () => setActiveTool('grab'), - title: "Grab Tool" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "hand", - className: "w-4 h-4" - }))), /*#__PURE__*/React.createElement("button", { - className: `p-1 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`, - onClick: () => setActiveTool('razor'), - title: "Razor Tool" - }, /*#__PURE__*/React.createElement("svg", { - className: "w-4 h-4 text-orange-400", - viewBox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - strokeWidth: "2.5", - strokeLinecap: "round", - strokeLinejoin: "round" - }, /*#__PURE__*/React.createElement("path", { - d: "M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" - }), /*#__PURE__*/React.createElement("path", { - d: "M4 9h16l-3 9H7z" - }), /*#__PURE__*/React.createElement("circle", { - cx: "12", - cy: "6", - r: "1" - }))), /*#__PURE__*/React.createElement("button", { - className: `p-1 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`, - onClick: () => setActiveTool('pen'), - title: "Pen Tool" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "pen-tool", - className: "w-4 h-4" - }))), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-4 bg-zinc-800 mx-0.5" - }), /*#__PURE__*/React.createElement("button", { - onClick: onGlue, - className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition", - title: "Glue Clips" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "link", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onCut, - className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition", - title: "Cut (Ctrl+X)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onCopy, - className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition", - title: "Copy (Ctrl+C)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "copy", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onPaste, - className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition", - title: "Paste (Ctrl+V)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "clipboard", - className: "w-3.5 h-3.5" - })))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1 border-r border-zinc-700 pr-3" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 font-bold uppercase" - }, "Snap"), /*#__PURE__*/React.createElement("select", { - value: snapValue || 'free', - onChange: e => onSnapChange(e.target.value), - className: "bg-zinc-850 text-zinc-300 text-xs px-1 py-0.5 rounded border border-zinc-800 focus:outline-none font-mono" - }, /*#__PURE__*/React.createElement("option", { - value: "free" - }, "Free"), /*#__PURE__*/React.createElement("option", { - value: "1" - }, "1"), /*#__PURE__*/React.createElement("option", { - value: "1/2" - }, "1/2"), /*#__PURE__*/React.createElement("option", { - value: "1/4" - }, "1/4"), /*#__PURE__*/React.createElement("option", { - value: "1/8" - }, "1/8"), /*#__PURE__*/React.createElement("option", { - value: "1/16" - }, "1/16"), /*#__PURE__*/React.createElement("option", { - value: "1/32" - }, "1/32"))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-400" - }, "Normalize:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-12", - max: "0", - value: st.effects?.normalizeDb || 0, - step: "0.1", - onChange: e => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value)), - className: "w-20 h-1.5" - }), /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 w-10 text-right" - }, st.effects?.normalizeDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-400" - }, "Gain:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-40", - max: "24", - value: st.effects?.gainDb || 0, - step: "0.1", - onChange: e => handleSubTabGainWithValue(st.id, parseFloat(e.target.value)), - className: "w-20 h-1.5" - }), /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 w-10 text-right" - }, st.effects?.gainDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-400" - }, "Pitch:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-12", - max: "12", - value: st.effects?.pitch || 0, - step: "0.1", - onChange: e => handleSubTabPitch(st.id, parseFloat(e.target.value)), - className: "w-20 h-1.5" - }), /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 w-10 text-right" - }, st.effects?.pitch || 0, " st")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-400" - }, "Stretch:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "50", - max: "200", - value: st.effects?.speedStretch || 100, - step: "1", - onChange: e => handleSubTabStretch(st.id, parseInt(e.target.value)), - className: "w-20 h-1.5" - }), /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 w-10 text-right" - }, st.effects?.speedStretch || 100, "%"))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => handleSubTabFade(st.id, 'in'), - className: "px-2 py-1 text-xs rounded hover:bg-zinc-700" - }, "Fade In"), /*#__PURE__*/React.createElement("button", { - onClick: () => handleSubTabFade(st.id, 'out'), - className: "px-2 py-1 text-xs rounded hover:bg-zinc-700" - }, "Fade Out")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" - }, /*#__PURE__*/React.createElement("button", { - onClick: onRewind, - className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Rewind" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "skip-back", - className: "w-4 h-4" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onPlayPause, - className: `w-8 h-8 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, - title: isPlaying ? "Pause" : "Play" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": isPlaying ? 'pause' : 'play', - className: "w-4 h-4 fill-current" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onStop, - className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Stop" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "square", - className: "w-4 h-4 fill-current" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onForward, - className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Forward" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "skip-forward", - className: "w-4 h-4" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onLoop, - className: `w-8 h-8 flex items-center justify-center rounded border transition ${isLooping ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, - title: isLooping ? "Loop On" : "Loop Off" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "repeat", - className: "w-4 h-4" - }))), /*#__PURE__*/React.createElement("button", { - onClick: onRecord, - className: `w-8 h-8 flex items-center justify-center rounded border transition ${isRecording ? 'bg-red-600 text-white border-red-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, - title: isRecording ? "Recording" : "Record" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "circle", - className: "w-4 h-4" - })))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center space-x-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-400" - }, "Rate:"), /*#__PURE__*/React.createElement("select", { - value: st.playbackRate || 1, - onChange: e => onRateChange(parseFloat(e.target.value)), - className: "bg-zinc-700 text-zinc-100 text-xs px-1 py-0.5 rounded border border-zinc-600" - }, /*#__PURE__*/React.createElement("option", { - value: "0.5" - }, "0.5x"), /*#__PURE__*/React.createElement("option", { - value: "0.75" - }, "0.75x"), /*#__PURE__*/React.createElement("option", { - value: "1" - }, "1x"), /*#__PURE__*/React.createElement("option", { - value: "1.25" - }, "1.25x"), /*#__PURE__*/React.createElement("option", { - value: "1.5" - }, "1.5x"), /*#__PURE__*/React.createElement("option", { - value: "2" - }, "2x")))); -}; - -// ── Graph Editor Canvas for Volume/Pan/Fade Automation ── -const GraphEditorCanvas = ({ - buffer, - zoom, - timelineWidth, - volumeNodes, - panningNodes, - fadeInLen, - fadeOutLen, - onUpdateNodes, - graphMode -}) => { - const canvasRef = useRef(null); - const isDraggingNode = useRef(false); - const dragNodeIdx = useRef(-1); - const isCreatingNode = useRef(false); - const getNodes = () => graphMode === 'pan' ? panningNodes : volumeNodes; - const nodeLabel = n => graphMode === 'pan' ? `${n.pan.toFixed(2)}` : `${n.db.toFixed(1)}dB`; - const nodeY = (n, h) => { - if (graphMode === 'pan') return (1 - (n.pan + 1) / 2) * h; - const zeroY = 2 / 3 * h; - return n.db >= 0 ? zeroY - n.db / 3 * (2 / 3 * h) : zeroY + -n.db / 30 * (1 / 3 * h); - }; - const nodeValFromY = (y, h) => { - if (graphMode === 'pan') return -(y / h * 2 - 1); - const yOff = y / h; - return yOff <= 2 / 3 ? 3 * (1 - yOff * 3 / 2) : -30 * (yOff - 2 / 3) * 3; - }; - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = wrapper ? wrapper.clientWidth : 1200; - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); - const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; - canvas.width = Math.round(drawWidth * dpr); - canvas.height = Math.round(h * dpr); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${h}px`; - ctx.fillStyle = '#1a1a2e'; - ctx.fillRect(0, 0, drawWidth, h); - ctx.strokeStyle = '#2a2a4e'; - ctx.lineWidth = 0.5; - for (let t = 0; t <= buffer.duration; t += 0.5) { - const x = t / buffer.duration * drawWidth; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, h); - ctx.stroke(); - } - for (let i = 0; i <= 10; i++) { - const y = i / 10 * h; - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(drawWidth, y); - ctx.stroke(); - } - if (fadeInLen > 0) { - const fadeX = fadeInLen / buffer.duration * drawWidth; - ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; - ctx.fillRect(0, 0, fadeX, h); - } - if (fadeOutLen > 0) { - const fadeX = (buffer.duration - fadeOutLen) / buffer.duration * drawWidth; - const fadeW = fadeOutLen / buffer.duration * drawWidth; - ctx.fillStyle = 'rgba(239, 68, 68, 0.12)'; - ctx.fillRect(fadeX, 0, fadeW, h); - } - const nodes = getNodes(); - if (nodes.length > 0) { - ctx.strokeStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; - ctx.lineWidth = 2; - ctx.beginPath(); - nodes.forEach((n, i) => { - const x = n.time / buffer.duration * drawWidth; - const y = nodeY(n, h); - if (i === 0) ctx.moveTo(x, y);else ctx.lineTo(x, y); - }); - ctx.stroke(); - nodes.forEach((n, i) => { - const x = n.time / buffer.duration * drawWidth; - const y = nodeY(n, h); - ctx.fillStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; - ctx.beginPath(); - ctx.arc(x, y, 5, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = '#e4e4e7'; - ctx.font = '9px monospace'; - ctx.fillText(nodeLabel(n), x + 8, y + 3); - }); - } else { - ctx.fillStyle = '#52525b'; - ctx.font = '11px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(graphMode === 'pan' ? 'Click to add Pan points' : 'Click to add Volume points', drawWidth / 2, h / 2); - ctx.textAlign = 'start'; - } - const zeroY = graphMode === 'pan' ? h / 2 : nodeY({ - db: 0 - }, h); - ctx.strokeStyle = graphMode === 'pan' ? '#a855f744' : '#06b6d444'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(0, zeroY); - ctx.lineTo(drawWidth, zeroY); - ctx.stroke(); - ctx.setLineDash([]); - }, [buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]); - const handleMouseDown = e => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const time = x / rect.width * buffer.duration; - const val = nodeValFromY(y, rect.height); - const nodes = getNodes(); - const snapped = Math.max(-30, Math.min(3, val)); - const snappedPan = Math.max(-1, Math.min(1, val)); - const threshold = 12 / rect.width * buffer.duration; - const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); - if (nearIdx >= 0) { - isDraggingNode.current = true; - dragNodeIdx.current = nearIdx; - return; - } - const newNode = graphMode === 'pan' ? { - time: +time.toFixed(3), - pan: +snappedPan.toFixed(2) - } : { - time: +time.toFixed(3), - db: +snapped.toFixed(1) - }; - const sorted = [...nodes, newNode].sort((a, b) => a.time - b.time); - onUpdateNodes(sorted); - const newIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db)); - isDraggingNode.current = true; - dragNodeIdx.current = newIdx; - isCreatingNode.current = true; - }; - const handleMouseMove = e => { - if (!isDraggingNode.current || dragNodeIdx.current < 0) return; - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const time = Math.max(0, Math.min(buffer.duration, x / rect.width * buffer.duration)); - const val = nodeValFromY(y, rect.height); - const nodes = [...getNodes()]; - nodes[dragNodeIdx.current] = graphMode === 'pan' ? { - time: +time.toFixed(3), - pan: +Math.max(-1, Math.min(1, val)).toFixed(2) - } : { - time: +time.toFixed(3), - db: +Math.max(-30, Math.min(3, val)).toFixed(1) - }; - onUpdateNodes(nodes.sort((a, b) => a.time - b.time)); - }; - const handleMouseUp = () => { - isDraggingNode.current = false; - dragNodeIdx.current = -1; - isCreatingNode.current = false; - }; - const handleContextMenu = e => { - e.preventDefault(); - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const time = x / rect.width * buffer.duration; - const threshold = 12 / rect.width * buffer.duration; - const nodes = getNodes(); - const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); - if (nearIdx >= 0) onUpdateNodes(nodes.filter((_, i) => i !== nearIdx)); - }; - return /*#__PURE__*/React.createElement("div", { - style: { - width: `${timelineWidth}px`, - height: '100%', - position: 'relative', - overflow: 'hidden' - } - }, /*#__PURE__*/React.createElement("canvas", { - ref: canvasRef, - style: { - position: 'absolute', - top: 0, - left: 0, - imageRendering: 'pixelated' - }, - className: "cursor-crosshair rounded border border-zinc-700", - onMouseDown: handleMouseDown, - onMouseMove: handleMouseMove, - onMouseUp: handleMouseUp, - onMouseLeave: handleMouseUp, - onContextMenu: handleContextMenu - })); -}; -const AuthModal = ({ - isOpen, - mode, - forceMandatory, - onClose, - onSuccess -}) => { - if (!isOpen) return null; - const [activeTab, setActiveTab] = useState(mode || 'login'); - const [username, setUsername] = useState('admin'); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - useEffect(() => { - if (mode) setActiveTab(mode); - if (mode === 'force_change' && !oldPassword) { - setOldPassword('admin123'); - } - }, [mode]); - const handleSubmit = async e => { - e.preventDefault(); - setError(''); - setLoading(true); - try { - if (activeTab === 'login') { - const targetUsername = username.trim() || 'admin'; - const targetPwd = password.trim() || 'admin123'; - const res = await window.SonicAPI.login(targetUsername, targetPwd); - localStorage.setItem('sonic_token', res.access_token); - localStorage.setItem('sonic_user', JSON.stringify(res.user)); - if (res.user && res.user.must_change_password) { - setOldPassword(targetPwd); - } - onSuccess(res.user, res.access_token); - } else if (activeTab === 'register') { - const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim()); - localStorage.setItem('sonic_token', res.access_token); - localStorage.setItem('sonic_user', JSON.stringify(res.user)); - onSuccess(res.user, res.access_token); - } else if (activeTab === 'force_change') { - const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim()); - localStorage.setItem('sonic_token', res.access_token); - const user = JSON.parse(localStorage.getItem('sonic_user') || '{}'); - user.must_change_password = false; - localStorage.setItem('sonic_user', JSON.stringify(user)); - onSuccess(user, res.access_token); - } - } catch (err) { - setError(err.message || (activeTab === 'login' ? 'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)' : 'Thao tác không thành công')); - } finally { - setLoading(false); - } - }; - const isForceMode = activeTab === 'force_change'; - const canClose = !forceMandatory && !isForceMode; - return /*#__PURE__*/React.createElement("div", { - className: "fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md" - }, /*#__PURE__*/React.createElement("div", { - className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" - }, /*#__PURE__*/React.createElement("div", { - className: "flex justify-between items-center pb-4 border-b border-[#383838]" - }, /*#__PURE__*/React.createElement("h3", { - className: "text-lg font-bold text-teal-400" - }, isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản'), canClose && /*#__PURE__*/React.createElement("button", { - onClick: onClose, - className: "text-slate-400 hover:text-slate-200" - }, "✕")), error && /*#__PURE__*/React.createElement("div", { - className: "mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm" - }, error), /*#__PURE__*/React.createElement("form", { - onSubmit: handleSubmit, - className: "mt-4 space-y-4" - }, isForceMode ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("p", { - className: "text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed" - }, "🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold mb-1 text-slate-400" - }, "Mật khẩu hiện tại (Mặc định: admin123)"), /*#__PURE__*/React.createElement("input", { - type: "password", - autoComplete: "current-password", - required: true, - value: oldPassword, - onChange: e => setOldPassword(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" - })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold mb-1 text-slate-400" - }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { - type: "password", - autoComplete: "new-password", - required: true, - value: newPassword, - onChange: e => setNewPassword(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" - }))) : /*#__PURE__*/React.createElement(React.Fragment, null, activeTab === 'login' ? /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold mb-1 text-slate-400" - }, "Tên đăng nhập ", /*#__PURE__*/React.createElement("span", { - className: "text-teal-400 font-normal" - }, "(Tùy chọn - Admin có thể bỏ trống)")), /*#__PURE__*/React.createElement("input", { - type: "text", - placeholder: "Mặc định: admin", - value: username, - onChange: e => setUsername(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" - })) : /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold mb-1 text-slate-400" - }, "Tên đăng nhập"), /*#__PURE__*/React.createElement("input", { - type: "text", - required: true, - value: username, - onChange: e => setUsername(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" - })), activeTab === 'register' && /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold mb-1 text-slate-400" - }, "Email"), /*#__PURE__*/React.createElement("input", { - type: "email", - required: true, - value: email, - onChange: e => setEmail(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" - })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold mb-1 text-slate-400" - }, "Mật khẩu ", activeTab === 'login' && /*#__PURE__*/React.createElement("span", { - className: "text-amber-400 font-normal" - }, "(Lần đầu: admin123)")), /*#__PURE__*/React.createElement("input", { - type: "password", - autoComplete: activeTab === 'login' ? 'current-password' : 'new-password', - required: true, - value: password, - onChange: e => setPassword(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" - }))), /*#__PURE__*/React.createElement("button", { - type: "submit", - disabled: loading, - className: "w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150" - }, loading ? 'Đang xác thực...' : isForceMode ? 'Đổi Mật Khẩu Ngay' : activeTab === 'login' ? 'Đăng Nhập System' : 'Tạo Tài Khoản Mới'), activeTab === 'login' && !isForceMode && /*#__PURE__*/React.createElement("button", { - type: "button", - onClick: () => { - setUsername('admin'); - setPassword('admin123'); - setError(''); - }, - className: "w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5" - }, "🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")), !isForceMode && /*#__PURE__*/React.createElement("div", { - className: "mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400" - }, activeTab === 'login' ? /*#__PURE__*/React.createElement("span", null, "Chưa có tài khoản? ", /*#__PURE__*/React.createElement("button", { - onClick: () => setActiveTab('register'), - className: "text-teal-400 hover:underline" - }, "Đăng ký ngay")) : /*#__PURE__*/React.createElement("span", null, "Đã có tài khoản? ", /*#__PURE__*/React.createElement("button", { - onClick: () => setActiveTab('login'), - className: "text-teal-400 hover:underline" - }, "Đăng nhập"))))); -}; -const AIConfigModal = ({ - isOpen, - onClose, - onConfigSaved -}) => { - if (!isOpen) return null; - const defaultProvidersList = [{ - id: 'openai_default', - name: 'OpenAI Official', - provider_type: 'openai', - api_base_url: 'https://api.openai.com/v1', - api_key: '', - model_name: 'gpt-4o', - temperature: 0.7, - is_active: true - }, { - id: 'openai_compat_default', - name: 'OpenAI Compatible (Ollama/LocalAI/DeepSeek)', - provider_type: 'openai_compatible', - api_base_url: 'http://localhost:11434/v1', - api_key: 'ollama', - model_name: 'deepseek-r1', - temperature: 0.7, - is_active: false - }, { - id: 'anthropic_default', - name: 'Anthropic Claude', - provider_type: 'anthropic', - api_base_url: 'https://api.anthropic.com/v1', - api_key: '', - model_name: 'claude-3-5-sonnet', - temperature: 0.7, - is_active: false - }, { - id: 'gemini_default', - name: 'Google Gemini', - provider_type: 'gemini', - api_base_url: 'https://generativelanguage.googleapis.com', - api_key: '', - model_name: 'gemini-1.5-pro', - temperature: 0.7, - is_active: false - }]; - const [providers, setProviders] = useState(defaultProvidersList); - const [selectedId, setSelectedId] = useState('openai_default'); - const [msg, setMsg] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - useEffect(() => { - if (isOpen) loadConfigs(); - }, [isOpen]); - const loadConfigs = async () => { - setLoading(true); - setError(''); - try { - const data = await window.SonicAPI.getAIConfigs(); - if (data && data.providers) { - setProviders(data.providers); - if (data.providers.length > 0) setSelectedId(data.providers[0].id); - } - } catch (err) { - setError(err.message || 'Lỗi nạp cấu hình AI'); - } finally { - setLoading(false); - } - }; - const handleSave = async e => { - e.preventDefault(); - setMsg(''); - setError(''); - setLoading(true); - try { - const res = await window.SonicAPI.saveAIConfigs(providers); - setMsg(res.message || 'Đã lưu cấu hình AI Providers thành công!'); - if (onConfigSaved) onConfigSaved(providers); - } catch (err) { - setError(err.message || 'Lỗi khi lưu cấu hình AI'); - } finally { - setLoading(false); - } - }; - const updateProviderField = (id, field, value) => { - setProviders(prev => prev.map(p => p.id === id ? { - ...p, - [field]: value - } : p)); - }; - const activeProvider = providers.find(p => p.id === selectedId) || providers[0]; - return /*#__PURE__*/React.createElement("div", { - className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none" - }, /*#__PURE__*/React.createElement("div", { - className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200" - }, /*#__PURE__*/React.createElement("div", { - className: "flex justify-between items-center pb-4 border-b border-[#383838]" - }, /*#__PURE__*/React.createElement("h3", { - className: "text-lg font-bold text-cyan-400 flex items-center gap-2" - }, "🤖 Quản Lý & Cấu Hình AI Providers"), /*#__PURE__*/React.createElement("button", { - onClick: onClose, - className: "text-slate-400 hover:text-slate-200" - }, "✕")), msg && /*#__PURE__*/React.createElement("div", { - className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" - }, msg), error && /*#__PURE__*/React.createElement("div", { - className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" - }, error), /*#__PURE__*/React.createElement("div", { - className: "mt-4 grid grid-cols-3 gap-4" - }, /*#__PURE__*/React.createElement("div", { - className: "space-y-1.5 border-r border-[#383838] pr-3" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs uppercase font-bold text-slate-400 block mb-2" - }, "Providers"), providers.map(p => /*#__PURE__*/React.createElement("button", { - key: p.id, - tabIndex: 0, - onClick: () => setSelectedId(p.id), - onKeyDown: e => { - if (e.key === ' ') { - e.preventDefault(); - updateProviderField(p.id, 'is_active', !p.is_active); - } - }, - className: `w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId === p.id ? 'bg-cyan-600 text-white shadow' : 'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}` - }, /*#__PURE__*/React.createElement("span", { - className: "truncate" - }, p.name), p.is_active && /*#__PURE__*/React.createElement("span", { - className: "w-2 h-2 rounded-full bg-emerald-400" - }))), /*#__PURE__*/React.createElement("div", { - className: "flex gap-1 mt-2" - }, /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - const newId = 'provider_' + Date.now(); - setProviders(prev => [...prev, { - id: newId, - name: 'New Provider', - provider_type: 'openai_compatible', - api_base_url: 'https://api.openai.com/v1', - api_key: '', - model_name: 'gpt-4o-mini', - temperature: 0.7, - is_active: false - }]); - setSelectedId(newId); - }, - className: "flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded" - }, "+ Thêm"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - if (confirm(`Xóa provider "${providers.find(p => p.id === selectedId)?.name}"?`)) { - setProviders(prev => { - const filtered = prev.filter(p => p.id !== selectedId); - if (filtered.length > 0) setSelectedId(filtered[0].id); - return filtered; - }); - } - }, - className: "px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded" - }, "Xóa"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { - onClick: function (e) { - e.stopPropagation(); - var provs = providers.slice(); - var pos = -1; - for (var pi = 0; pi < provs.length; pi++) { - if (provs[pi].id === selectedId) { - pos = pi; - break; - } - } - if (pos > 0) { - var tmp = provs[pos]; - provs[pos] = provs[pos - 1]; - provs[pos - 1] = tmp; - setProviders(provs); - } - }, - className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", - title: "Di chuyển lên" - }, "▲"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { - onClick: function (e) { - e.stopPropagation(); - var provs = providers.slice(); - var pos = -1; - for (var pi = 0; pi < provs.length; pi++) { - if (provs[pi].id === selectedId) { - pos = pi; - break; - } - } - if (pos < provs.length - 1) { - var tmp = provs[pos]; - provs[pos] = provs[pos + 1]; - provs[pos + 1] = tmp; - setProviders(provs); - } - }, - className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", - title: "Di chuyển xuống" - }, "▼"))), activeProvider && /*#__PURE__*/React.createElement("form", { - onSubmit: handleSave, - className: "col-span-2 space-y-3.5" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold text-slate-400 mb-1" - }, "Tên Provider"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: activeProvider.name, - onChange: e => updateProviderField(activeProvider.id, 'name', e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" - })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold text-slate-400 mb-1" - }, "API Base URL (Endpoint)"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: activeProvider.api_base_url || '', - onChange: e => updateProviderField(activeProvider.id, 'api_base_url', e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500", - placeholder: "https://api.openai.com/v1" - })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold text-slate-400 mb-1" - }, "API Key Cá Nhân"), /*#__PURE__*/React.createElement("input", { - type: "password", - value: activeProvider.api_key || '', - onChange: e => updateProviderField(activeProvider.id, 'api_key', e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500", - placeholder: "sk-..." - })), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-3" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold text-slate-400 mb-1" - }, "Model Name"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: activeProvider.model_name || '', - onChange: e => updateProviderField(activeProvider.id, 'model_name', e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500" - })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-xs font-semibold text-slate-400 mb-1" - }, "Temperature"), /*#__PURE__*/React.createElement("input", { - type: "number", - step: "0.1", - min: "0", - max: "2", - value: activeProvider.temperature ?? 0.7, - onChange: e => updateProviderField(activeProvider.id, 'temperature', parseFloat(e.target.value)), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" - }))), /*#__PURE__*/React.createElement("div", { - className: "pt-2 flex items-center justify-between" - }, /*#__PURE__*/React.createElement("label", { - className: "flex items-center gap-2 cursor-pointer text-xs text-slate-300" - }, /*#__PURE__*/React.createElement("input", { - type: "checkbox", - checked: activeProvider.is_active, - onChange: e => updateProviderField(activeProvider.id, 'is_active', e.target.checked), - className: "rounded accent-cyan-500" - }), "Kích hoạt Provider này"), /*#__PURE__*/React.createElement("button", { - type: "submit", - disabled: loading, - className: "px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition" - }, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI')))))); -}; -const ProfileModal = ({ - isOpen, - onClose, - tracks, - setTracks, - setSelectedTrackId, - projectName, - setProjectName, - currentProjectId, - setCurrentProjectId, - showToast -}) => { - if (!isOpen) return null; - const [activeTab, setActiveTab] = useState('account'); - const [profile, setProfile] = useState(null); - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [msg, setMsg] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - const [dragOfs, setDragOfs] = useState({ - x: 0, - y: 0 - }); - const dragRef = useRef({ - active: false, - startX: 0, - startY: 0, - ofsX: 0, - ofsY: 0 - }); - - // Projects list state - const [projectsList, setProjectsList] = useState([]); - const [loadingProjects, setLoadingProjects] = useState(false); - - // Files list state - const [filesList, setFilesList] = useState([]); - const [loadingFiles, setLoadingFiles] = useState(false); - - // Confirmation modal state - const [confirmModal, setConfirmModal] = useState(null); - const handleDragStart = e => { - const r = dragRef.current; - r.active = true; - r.startX = e.clientX; - r.startY = e.clientY; - r.ofsX = dragOfs.x; - r.ofsY = dragOfs.y; - const onMove = ev => { - if (!r.active) return; - setDragOfs({ - x: r.ofsX + ev.clientX - r.startX, - y: r.ofsY + ev.clientY - r.startY - }); - }; - const onUp = () => { - r.active = false; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }; - useEffect(() => { - if (isOpen) { - fetchProfile(); - if (activeTab === 'projects') fetchProjects(); - if (activeTab === 'files') fetchFiles(); - } - }, [isOpen, activeTab]); - const fetchProfile = async () => { - try { - const data = await window.SonicAPI.getProfile(); - setProfile(data); - } catch (e) { - setError(e.message || 'Không thể tải thông tin profile'); - } - }; - const fetchProjects = async () => { - setLoadingProjects(true); - try { - const data = await window.SonicAPI.listCloudProjects(); - setProjectsList(data || []); - } catch (e) { - showToast(e.message || 'Không thể tải danh sách dự án', 'error'); - } finally { - setLoadingProjects(false); - } - }; - const fetchFiles = async () => { - setLoadingFiles(true); - try { - const activeFileIds = tracks.map(t => t.serverFileId).filter(Boolean); - const data = await window.SonicAPI.listMyFiles(activeFileIds); - setFilesList(data || []); - } catch (e) { - showToast(e.message || 'Không thể tải danh sách tệp tin', 'error'); - } finally { - setLoadingFiles(false); - } - }; - const handleOpenProject = async projectId => { - if (!confirm("Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.")) return; - try { - const proj = await window.SonicAPI.getCloudProject(projectId); - const parsed = JSON.parse(proj.data_json); - const restored = (parsed.tracks || []).map(t => ({ - ...t, - buffer: null, - channelInfo: t.channelInfo || null, - clips: t.clips || [], - serverFileId: t.serverFileId || null - })); - setTracks(restored); - setSelectedTrackId(restored[0]?.id || '1'); - setProjectName(proj.name); - setCurrentProjectId(proj.id); - localStorage.setItem('sonic_project_name', proj.name); - localStorage.setItem('sonic_project_id', proj.id); - showToast(`Đã nạp dự án "${proj.name}" thành công!`, "success"); - onClose(); - } catch (e) { - showToast(e.message || "Lỗi khi nạp dự án", "error"); - } - }; - const handleDeleteProject = async (projectId, e) => { - e.stopPropagation(); - setConfirmModal({ - title: "Xóa dự án Cloud", - message: "Bạn có chắc chắn muốn xóa dự án này khỏi Cloud? Hành động này không thể hoàn tác.", - onConfirm: async () => { - try { - await window.SonicAPI.deleteCloudProject(projectId); - showToast("Đã xóa dự án thành công!", "success"); - fetchProjects(); - fetchProfile(); - } catch (err) { - showToast(err.message || "Lỗi khi xóa dự án", "error"); - } - } - }); - }; - const handleDeleteFile = async fileId => { - if (!confirm(`Bạn có chắc chắn muốn xóa tệp tin ${fileId}?`)) return; - try { - await window.SonicAPI.deleteMyFile(fileId); - showToast("Đã xóa tệp tin thành công!", "success"); - fetchFiles(); - fetchProfile(); - } catch (err) { - showToast(err.message || "Lỗi khi xóa tệp tin", "error"); - } - }; - const handleCleanUnusedFiles = async () => { - const unusedFiles = filesList.filter(f => !f.is_in_use); - if (unusedFiles.length === 0) { - showToast("Không có tập tin rác nào để dọn dẹp.", "info"); - return; - } - if (!confirm(`Bạn có chắc chắn muốn xóa tất cả ${unusedFiles.length} tập tin rác để giải phóng dung lượng?`)) return; - let successCount = 0; - for (const file of unusedFiles) { - try { - await window.SonicAPI.deleteMyFile(file.file_id); - successCount++; - } catch (e) { - console.error("Lỗi xóa file rác: ", file.file_id, e); - } - } - showToast(`Đã dọn dẹp thành công ${successCount}/${unusedFiles.length} tập tin rác!`, "success"); - fetchFiles(); - fetchProfile(); - }; - const handleChangePassword = async e => { - e.preventDefault(); - setMsg(''); - setError(''); - setLoading(true); - try { - const res = await window.SonicAPI.changePassword(oldPassword, newPassword); - setMsg(res.message || 'Đổi mật khẩu thành công!'); - setOldPassword(''); - setNewPassword(''); - } catch (err) { - setError(err.message || 'Lỗi khi đổi mật khẩu'); - } finally { - setLoading(false); - } - }; - const modalStyle = { - left: `calc(50% + ${dragOfs.x}px)`, - top: `calc(50% + ${dragOfs.y}px)`, - transform: 'translate(-50%, -50%)' - }; - return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { - key: "backdrop", - className: "fixed inset-0 z-40 bg-black/70 backdrop-blur-sm", - onClick: onClose - }), confirmModal && /*#__PURE__*/React.createElement("div", { - key: "confirm-overlay", - className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/50" - }, /*#__PURE__*/React.createElement("div", { - className: "bg-[#262626] border border-[#383838] rounded-lg shadow-2xl p-5 max-w-sm w-full text-slate-200" - }, /*#__PURE__*/React.createElement("h4", { - className: "text-sm font-bold text-rose-400 mb-2" - }, confirmModal.title), /*#__PURE__*/React.createElement("p", { - className: "text-xs text-slate-300 mb-4" - }, confirmModal.message), /*#__PURE__*/React.createElement("div", { - className: "flex justify-end gap-2" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => setConfirmModal(null), - className: "px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold" - }, "Hủy"), /*#__PURE__*/React.createElement("button", { - onClick: () => { - const fn = confirmModal.onConfirm; - setConfirmModal(null); - fn(); - }, - className: "px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold" - }, "Xác nhận xóa")))), /*#__PURE__*/React.createElement("div", { - key: "dialog", - className: "fixed z-50 bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200 flex flex-col max-h-[85vh]", - style: modalStyle - }, /*#__PURE__*/React.createElement("div", { - className: "flex justify-between items-center pb-3 border-b border-[#383838] shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: handleDragStart - }, /*#__PURE__*/React.createElement("h3", { - className: "text-md font-bold text-teal-400 flex items-center gap-1.5" - }, "👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"), /*#__PURE__*/React.createElement("button", { - onClick: onClose, - className: "text-slate-400 hover:text-slate-200" - }, "✕")), /*#__PURE__*/React.createElement("div", { - className: "flex gap-2 border-b border-[#383838] py-2 shrink-0 text-xs font-semibold" - }, [/*#__PURE__*/React.createElement("button", { - key: "tab-acc", - onClick: () => setActiveTab('account'), - className: `px-3 py-1.5 rounded transition ${activeTab === 'account' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}` - }, "Tài Khoản"), /*#__PURE__*/React.createElement("button", { - key: "tab-proj", - onClick: () => setActiveTab('projects'), - className: `px-3 py-1.5 rounded transition ${activeTab === 'projects' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}` - }, "Dự Án Cloud"), /*#__PURE__*/React.createElement("button", { - key: "tab-files", - onClick: () => setActiveTab('files'), - className: `px-3 py-1.5 rounded transition ${activeTab === 'files' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}` - }, "Tập Tin Của Tôi")]), /*#__PURE__*/React.createElement("div", { - className: "flex-1 overflow-y-auto pt-4 space-y-4 pr-1 min-h-[300px]" - }, activeTab === 'account' && profile ? [/*#__PURE__*/React.createElement("div", { - key: "quota-info", - className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs" - }, [/*#__PURE__*/React.createElement("div", { - key: "username" - }, [/*#__PURE__*/React.createElement("span", { - className: "text-slate-500 block" - }, "Tên người dùng"), /*#__PURE__*/React.createElement("span", { - className: "font-bold text-teal-300 text-sm" - }, profile.username)]), /*#__PURE__*/React.createElement("div", { - key: "role" - }, [/*#__PURE__*/React.createElement("span", { - className: "text-slate-500 block" - }, "Vai trò"), /*#__PURE__*/React.createElement("span", { - className: "uppercase font-semibold text-amber-400" - }, profile.role)]), /*#__PURE__*/React.createElement("div", { - key: "email" - }, [/*#__PURE__*/React.createElement("span", { - className: "text-slate-500 block" - }, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)]), /*#__PURE__*/React.createElement("div", { - key: "quota" - }, [/*#__PURE__*/React.createElement("span", { - className: "text-slate-500 block" - }, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", { - className: "font-semibold text-slate-200" - }, `${profile.quota.used_mb} MB / ${profile.quota.storage_limit_mb} MB`)])]), /*#__PURE__*/React.createElement("div", { - key: "progress" - }, [/*#__PURE__*/React.createElement("div", { - className: "flex justify-between text-xs mb-1" - }, [/*#__PURE__*/React.createElement("span", { - className: "text-slate-400" - }, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", { - className: "font-bold text-teal-400" - }, `${(profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1)}%`)]), /*#__PURE__*/React.createElement("div", { - className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden" - }, [/*#__PURE__*/React.createElement("div", { - className: "h-full bg-teal-500 rounded-full transition-all duration-300", - style: { - width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%` - } - })])]), /*#__PURE__*/React.createElement("form", { - key: "pwd-form", - onSubmit: handleChangePassword, - className: "pt-4 border-t border-[#383838] space-y-3" - }, [/*#__PURE__*/React.createElement("h4", { - className: "text-xs font-bold text-slate-300 uppercase" - }, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", { - className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" - }, msg), error && /*#__PURE__*/React.createElement("div", { - className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" - }, error), /*#__PURE__*/React.createElement("div", { - key: "old" - }, [/*#__PURE__*/React.createElement("label", { - className: "block text-xs text-slate-400 mb-1" - }, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", { - type: "password", - autoComplete: "current-password", - required: true, - value: oldPassword, - onChange: e => setOldPassword(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" - })]), /*#__PURE__*/React.createElement("div", { - key: "new" - }, [/*#__PURE__*/React.createElement("label", { - className: "block text-xs text-slate-400 mb-1" - }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { - type: "password", - autoComplete: "new-password", - required: true, - value: newPassword, - onChange: e => setNewPassword(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" - })]), /*#__PURE__*/React.createElement("button", { - type: "submit", - disabled: loading, - className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition" - }, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu')])] : activeTab === 'projects' ? [loadingProjects ? /*#__PURE__*/React.createElement("div", { - key: "loading", - className: "text-center py-8 text-xs text-zinc-500" - }, "Đang tải danh sách dự án...") : projectsList.length === 0 ? /*#__PURE__*/React.createElement("div", { - key: "empty", - className: "text-center py-8 text-xs text-zinc-500" - }, "Bạn chưa có dự án nào lưu trên Cloud.") : /*#__PURE__*/React.createElement("div", { - key: "list", - className: "space-y-1.5" - }, [projectsList.map(proj => /*#__PURE__*/React.createElement("div", { - key: proj.id, - onClick: () => handleOpenProject(proj.id), - className: "flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group" - }, [/*#__PURE__*/React.createElement("div", { - key: "meta" - }, [/*#__PURE__*/React.createElement("div", { - className: "font-bold text-slate-200 group-hover:text-teal-400" - }, proj.name), /*#__PURE__*/React.createElement("div", { - className: "text-[10px] text-zinc-500 mt-0.5" - }, `Dung lượng: ${proj.size_mb} MB | Cập nhật: ${new Date(proj.updated_at * 1000).toLocaleString()}`)]), /*#__PURE__*/React.createElement("div", { - key: "actions", - className: "flex items-center gap-1.5" - }, [/*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - handleOpenProject(proj.id); - }, - className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]" - }, "MỞ"), /*#__PURE__*/React.createElement("button", { - onClick: e => handleDeleteProject(proj.id, e), - className: "px-2 py-1 bg-rose-950/60 hover:bg-rose-900/80 text-rose-300 rounded font-semibold text-[10px]" - }, "XÓA")])]))])] : activeTab === 'files' ? [/*#__PURE__*/React.createElement("div", { - key: "cleanup-header", - className: "flex items-center justify-between bg-zinc-900/60 p-2.5 rounded border border-zinc-800 text-xs shrink-0 mb-3" - }, [/*#__PURE__*/React.createElement("span", { - className: "text-zinc-400 text-[10px]" - }, "💡 Các tập tin không dùng trong dự án nào sẽ được đánh dấu 'Không dùng' và có thể xóa để giải phóng dung lượng."), /*#__PURE__*/React.createElement("button", { - onClick: handleCleanUnusedFiles, - className: "px-2.5 py-1 bg-rose-700 hover:bg-rose-600 text-white rounded font-bold text-[10px] uppercase flex items-center gap-1" - }, "🧹 Dọn dẹp tệp rác")]), loadingFiles ? /*#__PURE__*/React.createElement("div", { - key: "loading", - className: "text-center py-8 text-xs text-zinc-500" - }, "Đang tải danh sách tập tin...") : filesList.length === 0 ? /*#__PURE__*/React.createElement("div", { - key: "empty", - className: "text-center py-8 text-xs text-zinc-500" - }, "Chưa có tập tin nào tải lên hoặc tạo ra.") : /*#__PURE__*/React.createElement("div", { - key: "list", - className: "space-y-1.5" - }, [filesList.map(file => /*#__PURE__*/React.createElement("div", { - key: file.file_id, - className: "flex items-center justify-between p-3 bg-[#1e1e1e] rounded border border-zinc-800 text-xs" - }, [/*#__PURE__*/React.createElement("div", { - key: "meta", - className: "max-w-[70%]" - }, [/*#__PURE__*/React.createElement("div", { - className: "font-semibold text-slate-300 truncate" - }, file.original_name || file.file_id), /*#__PURE__*/React.createElement("div", { - className: "text-[10px] text-zinc-500 mt-0.5" - }, `Loại: ${file.type} | Dung lượng: ${file.size_mb} MB | Tạo lúc: ${new Date(file.created_at * 1000).toLocaleString()}`)]), /*#__PURE__*/React.createElement("div", { - key: "actions", - className: "flex items-center gap-2" - }, [file.is_in_use ? /*#__PURE__*/React.createElement("span", { - className: "px-2 py-0.5 bg-emerald-950 text-emerald-400 border border-emerald-900 rounded text-[9px] uppercase font-bold font-mono" - }, "Đang dùng") : /*#__PURE__*/React.createElement("span", { - className: "px-2 py-0.5 bg-zinc-850 text-zinc-400 border border-zinc-700 rounded text-[9px] uppercase font-bold font-mono" - }, "Không dùng"), !file.is_in_use && /*#__PURE__*/React.createElement("button", { - onClick: () => handleDeleteFile(file.file_id), - className: "px-2 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded font-semibold text-[10px] border border-rose-900" - }, "Xóa")])]))])] : null))); -}; -const SaveProjectModal = ({ - isOpen, - onClose, - onSave -}) => { - if (!isOpen) return null; - const [name, setName] = useState(''); - const handleSubmit = e => { - e.preventDefault(); - if (!name.trim()) return; - onSave(name.trim()); - onClose(); - }; - return /*#__PURE__*/React.createElement("div", { - className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" - }, /*#__PURE__*/React.createElement("div", { - className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" - }, /*#__PURE__*/React.createElement("h3", { - className: "text-sm font-bold text-teal-400 mb-4 uppercase" - }, "Đặt tên dự án"), /*#__PURE__*/React.createElement("form", { - onSubmit: handleSubmit, - className: "space-y-4" - }, [/*#__PURE__*/React.createElement("input", { - key: "name-input", - type: "text", - placeholder: "Nhập tên dự án...", - required: true, - value: name, - onChange: e => setName(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold", - autoFocus: true - }), /*#__PURE__*/React.createElement("div", { - key: "actions", - className: "flex justify-end gap-2 text-xs" - }, [/*#__PURE__*/React.createElement("button", { - key: "cancel", - type: "button", - onClick: onClose, - className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" - }, "Hủy"), /*#__PURE__*/React.createElement("button", { - key: "save", - type: "submit", - className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold" - }, "Lưu")])]))); -}; -const SaveAsModal = ({ - isOpen, - onClose, - projectName, - onSaveCloud, - onSaveLocal -}) => { - if (!isOpen) return null; - const [name, setName] = useState(projectName || ''); - const [saveType, setSaveType] = useState('cloud'); - const handleSubmit = e => { - e.preventDefault(); - if (!name.trim()) return; - if (saveType === 'cloud') { - onSaveCloud(name.trim()); - } else { - onSaveLocal(name.trim()); - } - onClose(); - }; - return /*#__PURE__*/React.createElement("div", { - className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" - }, /*#__PURE__*/React.createElement("div", { - className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" - }, /*#__PURE__*/React.createElement("h3", { - className: "text-sm font-bold text-teal-400 mb-4 uppercase" - }, "Lưu dưới tên khác (Save As...)"), /*#__PURE__*/React.createElement("form", { - onSubmit: handleSubmit, - className: "space-y-4" - }, [/*#__PURE__*/React.createElement("div", { - key: "name-block" - }, [/*#__PURE__*/React.createElement("label", { - className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1" - }, "Tên dự án mới"), /*#__PURE__*/React.createElement("input", { - type: "text", - placeholder: "Nhập tên mới...", - required: true, - value: name, - onChange: e => setName(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold", - autoFocus: true - })]), /*#__PURE__*/React.createElement("div", { - key: "type-block" - }, [/*#__PURE__*/React.createElement("label", { - className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1.5" - }, "Phương thức lưu trữ"), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-2 text-xs" - }, [/*#__PURE__*/React.createElement("button", { - key: "btn-cloud", - type: "button", - onClick: () => setSaveType('cloud'), - className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}` - }, [/*#__PURE__*/React.createElement("span", { - key: "title" - }, "☁️ Lưu Cloud"), /*#__PURE__*/React.createElement("span", { - key: "desc", - className: "text-[9px] font-normal text-zinc-500" - }, "Lưu lên server cá nhân")]), /*#__PURE__*/React.createElement("button", { - key: "btn-local", - type: "button", - onClick: () => setSaveType('local'), - className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}` - }, [/*#__PURE__*/React.createElement("span", { - key: "title" - }, "💾 Tải về máy (.sfs)"), /*#__PURE__*/React.createElement("span", { - key: "desc", - className: "text-[9px] font-normal text-zinc-500" - }, "Tải tệp JSON dự án về máy")])])]), /*#__PURE__*/React.createElement("div", { - key: "actions", - className: "flex justify-end gap-2 text-xs pt-2" - }, [/*#__PURE__*/React.createElement("button", { - key: "cancel", - type: "button", - onClick: onClose, - className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" - }, "Hủy"), /*#__PURE__*/React.createElement("button", { - key: "save", - type: "submit", - className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold" - }, "Thực hiện lưu")])]))); -}; -const SystemManagerModal = ({ - isOpen, - onClose -}) => { - if (!isOpen) return null; - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [msg, setMsg] = useState(''); - const [error, setError] = useState(''); - const [editingQuotaUser, setEditingQuotaUser] = useState(null); - const [newQuotaMb, setNewQuotaMb] = useState(500); - useEffect(() => { - if (isOpen) loadUsers(); - }, [isOpen]); - const loadUsers = async () => { - setLoading(true); - setError(''); - try { - const data = await window.SonicAPI.listUsers(); - setUsers(data); - } catch (err) { - setError(err.message || 'Không thể tải danh sách người dùng hệ thống'); - } finally { - setLoading(false); - } - }; - const handleSaveQuota = async userId => { - try { - await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb)); - setMsg('Đã cập nhật hạn mức Quota thành công!'); - setEditingQuotaUser(null); - loadUsers(); - } catch (err) { - setError(err.message || 'Lỗi cập nhật Quota'); - } - }; - const handleToggleRole = async user => { - const nextRole = user.role === 'admin' ? 'standard' : 'admin'; - try { - await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active); - setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`); - loadUsers(); - } catch (err) { - setError(err.message || 'Lỗi cập nhật vai trò'); - } - }; - const handleDeleteUser = async userId => { - if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return; - try { - await window.SonicAPI.deleteUser(userId); - setMsg('Đã xóa người dùng thành công'); - loadUsers(); - } catch (err) { - setError(err.message || 'Lỗi khi xóa người dùng'); - } - }; - return /*#__PURE__*/React.createElement("div", { - className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" - }, /*#__PURE__*/React.createElement("div", { - className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200" - }, /*#__PURE__*/React.createElement("div", { - className: "flex justify-between items-center pb-4 border-b border-[#383838]" - }, /*#__PURE__*/React.createElement("h3", { - className: "text-lg font-bold text-amber-400" - }, "⚙️ Quản Lý Hệ Thống & Phân Quyền Admin"), /*#__PURE__*/React.createElement("button", { - onClick: onClose, - className: "text-slate-400 hover:text-slate-200" - }, "✕")), msg && /*#__PURE__*/React.createElement("div", { - className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" - }, msg), error && /*#__PURE__*/React.createElement("div", { - className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" - }, error), /*#__PURE__*/React.createElement("div", { - className: "mt-4 overflow-x-auto max-h-96 no-scrollbar" - }, loading ? /*#__PURE__*/React.createElement("div", { - className: "py-8 text-center text-slate-400 text-xs" - }, "Đang tải thông tin hệ thống...") : /*#__PURE__*/React.createElement("table", { - className: "w-full text-left text-xs border-collapse" - }, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", { - className: "border-b border-[#383838] text-slate-400 bg-[#1e1e1e]" - }, /*#__PURE__*/React.createElement("th", { - className: "p-3" - }, "Tên Người Dùng"), /*#__PURE__*/React.createElement("th", { - className: "p-3" - }, "Email"), /*#__PURE__*/React.createElement("th", { - className: "p-3" - }, "Vai Trò"), /*#__PURE__*/React.createElement("th", { - className: "p-3" - }, "Dung Lượng Sử Dụng"), /*#__PURE__*/React.createElement("th", { - className: "p-3" - }, "Hạn Mức Quota"), /*#__PURE__*/React.createElement("th", { - className: "p-3 text-right" - }, "Thao Tác"))), /*#__PURE__*/React.createElement("tbody", { - className: "divide-y divide-[#333]" - }, users.map(u => /*#__PURE__*/React.createElement("tr", { - key: u.id, - className: "hover:bg-[#2e2e2e]" - }, /*#__PURE__*/React.createElement("td", { - className: "p-3 font-semibold text-teal-300" - }, u.username, u.must_change_password && /*#__PURE__*/React.createElement("span", { - className: "ml-2 text-xs bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded" - }, "Mật khẩu gốc")), /*#__PURE__*/React.createElement("td", { - className: "p-3 text-slate-300" - }, u.email), /*#__PURE__*/React.createElement("td", { - className: "p-3 uppercase font-bold text-amber-400" - }, u.role), /*#__PURE__*/React.createElement("td", { - className: "p-3" - }, u.used_mb, " MB"), /*#__PURE__*/React.createElement("td", { - className: "p-3" - }, editingQuotaUser === u.id ? /*#__PURE__*/React.createElement("div", { - className: "flex items-center space-x-1" - }, /*#__PURE__*/React.createElement("input", { - type: "number", - value: newQuotaMb, - onChange: e => setNewQuotaMb(e.target.value), - className: "w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200" - }), /*#__PURE__*/React.createElement("span", null, "MB"), /*#__PURE__*/React.createElement("button", { - onClick: () => handleSaveQuota(u.id), - className: "px-2 py-0.5 bg-teal-600 rounded text-xs" - }, "Lưu")) : /*#__PURE__*/React.createElement("span", { - className: "font-semibold" - }, u.quota_mb, " MB")), /*#__PURE__*/React.createElement("td", { - className: "p-3 text-right space-x-2" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => { - setEditingQuotaUser(u.id); - setNewQuotaMb(u.quota_mb); - }, - className: "px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs" - }, "Sửa Quota"), /*#__PURE__*/React.createElement("button", { - onClick: () => handleToggleRole(u), - className: "px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-xs" - }, "Đổi Role"), u.role !== 'admin' && /*#__PURE__*/React.createElement("button", { - onClick: () => handleDeleteUser(u.id), - className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs" - }, "Xóa"))))))))); -}; -const App = () => { - // ── State Definitions ── - const [tracks, setTracks] = useState([{ - id: '1', - name: 'Track 01', - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: '#0f766e', - markers: [], - serverFileId: null, - clips: [] - }, { - id: '2', - name: 'Track 02', - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: '#1d4ed8', - markers: [], - serverFileId: null, - clips: [], - sections: [], - midiItems: [] - }]); - const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); - const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color } - const [hoveredTrackId, setHoveredTrackId] = useState(null); - const openPanel = id => { - if (id === 'export') setShowExportPanel(true);else if (id === 'ai') setShowAIPanel(true);else if (id === 'python_tools') setShowPythonToolsPanel(true);else if (id === 'selection') setShowSelectionPanel(true); - }; - const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor' - const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' - - const snapTime = (time, snapVal, bpmVal) => { - if (snapVal === 'free') return time; - const beatDuration = 60 / parseFloat(bpmVal || 120); - let divisor = 1; - if (snapVal === '1') divisor = 1;else if (snapVal === '1/2') divisor = 0.5;else if (snapVal === '1/4') divisor = 0.25;else if (snapVal === '1/8') divisor = 0.125;else if (snapVal === '1/16') divisor = 0.0625;else if (snapVal === '1/32') divisor = 0.03125; - const gridSpacing = beatDuration * divisor; - return Math.round(time / gridSpacing) * gridSpacing; - }; - const snapValueRef = useRef(snapValue); - snapValueRef.current = snapValue; - const bpmRef = useRef(bpm); - bpmRef.current = bpm; - // BMP for Tempo Track - LOOP_EDITOR_2.md §6 - const [selectedTrackId, setSelectedTrackId] = useState('1'); - const [currentTime, setCurrentTime] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [selectionStart, setSelectionStart] = useState(null); - const [selectionEnd, setSelectionEnd] = useState(null); - const selectionRef = useRef({ - start: null, - end: null - }); - selectionRef.current = { - start: selectionStart, - end: selectionEnd - }; - const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track) - const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null); - const [localSelectionStart, setLocalSelectionStart] = useState(null); - const [localSelectionEnd, setLocalSelectionEnd] = useState(null); - const [zoom, setZoom] = useState(100); - const [isLoopingSelection, setIsLoopingSelection] = useState(false); - const [beginBar, setBeginBar] = useState(1); - const [endBar, setEndBar] = useState(1); - const [numberBar, setNumberBar] = useState(1); - const [subTabHeight, setSubTabHeight] = useState(96); - const [isExporting, setIsExporting] = useState(false); - const [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || ''); - const [currentProjectId, setCurrentProjectId] = useState(() => localStorage.getItem('sonic_project_id') || null); - const [saveProjectModalOpen, setSaveProjectModalOpen] = useState(false); - const [saveAsModalOpen, setSaveAsModalOpen] = useState(false); - const [soloedTrackId, setSoloedTrackId] = useState(null); - const [toastMessage, setToastMessage] = useState(null); - const [showAIConfig, setShowAIConfig] = useState(false); - const [showExportPanel, setShowExportPanel] = useState(true); - const [showAIPanel, setShowAIPanel] = useState(true); - const [showSelectionPanel, setShowSelectionPanel] = useState(true); - const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(true); - const [showMediaExplorer, setShowMediaExplorer] = useState(true); - const [showFxRack, setShowFxRack] = useState(false); - const [showMidiEvents, setShowMidiEvents] = useState(false); - const [rightSidebarWidth, setRightSidebarWidth] = useState(320); - const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50); - const [panelPositions, setPanelPositions] = useState({ - export: 'bottom', - ai: 'right', - python_tools: 'bottom', - selection: 'bottom', - media_explorer: 'bottom', - fx_rack: 'bottom', - midi_events: 'bottom' - }); - const [panelDropZone, setPanelDropZone] = useState(null); - const [dragGhostPos, setDragGhostPos] = useState(null); - const [dragGhostPanel, setDragGhostPanel] = useState(null); - const panelDragRef = useRef(null); - const workspaceRef = useRef(null); - const colResizerRef = useRef(null); - const rowResizerRef = useRef(null); - const [aiConfig, setAiConfig] = useState({ - baseUrl: localStorage.getItem('ai_base_url') || `${API_BASE_URL}`, - apiKey: localStorage.getItem('ai_api_key') || '', - model: localStorage.getItem('ai_model') || 'deepseek-chat' - }); - const [aiProviders, setAiProviders] = useState([]); - useEffect(() => { - (async () => { - try { - const data = await window.SonicAPI.getAIConfigs(); - if (data && data.providers) { - setAiProviders(data.providers); - const active = data.providers.find(p => p.is_active) || data.providers[0]; - if (active) setSelectedProviderId(active.id); - } - } catch (e) {/* server may not have config endpoint */} - })(); - }, []); - const [analysisState, setAnalysisState] = useState({ - status: 'Sẵn sàng. Chạy AI để phân tích nhịp.', - data: null, - isRunning: false - }); - const [aiPrompt, setAiPrompt] = useState(''); - const [promptHistory, setPromptHistory] = useState([]); - const [promptHistIdx, setPromptHistIdx] = useState(-1); - const promptHistRef = useRef([]); - const [aiProvider, setAiProvider] = useState('OpenAI'); - const [aiModel, setAiModel] = useState('GPT-4o'); - const [aiActionLog, setAiActionLog] = useState([]); - const actionLogContainerRef = useRef(null); - useEffect(() => { - if (actionLogContainerRef.current) { - actionLogContainerRef.current.scrollTop = actionLogContainerRef.current.scrollHeight; - } - }, [aiActionLog]); - const [aiProcessing, setAiProcessing] = useState(false); - const [selectedProviderId, setSelectedProviderId] = useState(''); - const [exportSettings, setExportSettings] = useState({ - sampleRate: '44100', - bitDepth: '16', - format: 'wav', - source: 'project', - quality: '44khz', - channels: 'stereo' - }); - const [serverStatus, setServerStatus] = useState('checking...'); - const [menuOpen, setMenuOpen] = useState(null); - const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } - const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } - const [draggedSectionItem, setDraggedSectionItem] = useState(null); - const [resizedSectionItem, setResizedSectionItem] = useState(null); - const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited - const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId } - const [editNameInput, setEditNameInput] = useState(''); - - // ── Context Menu & Clipboard ── - const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId } - const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste - - // ── Undo/Redo Engine (LOOP_EDITOR.md §4) ── - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); - const MAX_UNDO = 30; - const pushAction = (actionType, trackId, beforeState, afterState) => { - const node = { - action_type: actionType, - track_id: trackId, - timestamp: Date.now(), - before_state: beforeState, - after_state: afterState - }; - setUndoStack(prev => { - const next = [...prev, node]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - setRedoStack([]); - }; - const handleUndo = () => { - if (undoStack.length === 0) return; - const last = undoStack[undoStack.length - 1]; - setUndoStack(prev => prev.slice(0, -1)); - setRedoStack(prev => [...prev, last]); - applyTrackState(last.track_id, last.before_state); - showToast(`Undo: ${last.action_type}`, 'info'); - }; - const handleRedo = () => { - if (redoStack.length === 0) return; - const last = redoStack[redoStack.length - 1]; - setRedoStack(prev => prev.slice(0, -1)); - setUndoStack(prev => [...prev, last]); - applyTrackState(last.track_id, last.after_state); - showToast(`Redo: ${last.action_type}`, 'info'); - }; - const applyTrackState = (trackId, state) => { - setTracks(prev => prev.map(t => { - if (t.id !== trackId) return t; - return { - ...t, - ...state - }; - })); - }; - const captureTrackSnapshot = trackId => { - const track = tracks.find(t => t.id === trackId); - if (!track) return null; - return { - volumeDb: track.volumeDb, - pan: track.pan, - muted: track.muted, - name: track.name, - markers: JSON.parse(JSON.stringify(track.markers || [])), - // buffer is captured via reference copy for undo; we store a clone for redo - buffer: track.buffer, - startTime: track.startTime || 0, - clips: track.clips ? track.clips.map(c => ({ - id: c.id, - buffer: c.buffer, - startTime: c.startTime, - name: c.name - })) : null - }; - }; - - // ── Tab System (LOOP_EDITOR_2.md §1) ── - const [activeTab, setActiveTab] = useState('main'); - const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null); - const [subTabNormVal, setSubTabNormVal] = useState(0); - const [subTabGainVal, setSubTabGainVal] = useState(100); - const [subTabPitchVal, setSubTabPitchVal] = useState(0); - const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] - const [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...] - const activeTracks = useMemo(() => { - const st = sessionTabs.find(s => s.id === activeTab); - return st ? st.tracks : tracks; - }, [activeTab, sessionTabs, tracks]); - const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 - - // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── - const [tempTabActive, setTempTabActive] = useState(false); - const [tempTabBuffer, setTempTabBuffer] = useState(null); - const [tempTabTrackId, setTempTabTrackId] = useState(null); - const [tempTabOrigStart, setTempTabOrigStart] = useState(0); - const [tempTabOrigEnd, setTempTabOrigEnd] = useState(0); - const tempTabCanvasRef = useRef(null); - - // Effect parameters for temp tab - const [tempTabEffects, setTempTabEffects] = useState({ - reverse: false, - gainDb: 0, - fadeInMs: 0, - fadeOutMs: 0 - }); - - // ── Auth / User State ── - const [currentUser, setCurrentUser] = useState(null); - const [authModalOpen, setAuthModalOpen] = useState(false); - const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'force_change' - const [isMandatoryLogin, setIsMandatoryLogin] = useState(false); - const [profileModalOpen, setProfileModalOpen] = useState(false); - const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false); - const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false); - useEffect(() => { - const checkAuthStatus = async () => { - const savedToken = localStorage.getItem('sonic_token'); - if (!savedToken) { - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); - return; - } - try { - const profile = await window.SonicAPI.getProfile(); - setCurrentUser(profile); - localStorage.setItem('sonic_user', JSON.stringify(profile)); - if (profile.must_change_password) { - setIsMandatoryLogin(true); - setAuthMode('force_change'); - setAuthModalOpen(true); - } else { - setIsMandatoryLogin(false); - setAuthModalOpen(false); - } - } catch (err) { - const cached = localStorage.getItem('sonic_user'); - if (cached) { - try { - setCurrentUser(JSON.parse(cached)); - } catch (_) {} - setIsMandatoryLogin(false); - setAuthModalOpen(false); - } else { - localStorage.removeItem('sonic_token'); - localStorage.removeItem('sonic_user'); - setCurrentUser(null); - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); - } - } - }; - checkAuthStatus(); - }, []); - const loadPendingSfsProject = () => { - const proj = window.__pendingSfsProject; - if (!proj) return; - try { - const restored = (proj.tracks || []).map(t => ({ - ...t, - buffer: null, - channelInfo: t.channelInfo || null, - clips: t.clips || [], - serverFileId: t.serverFileId || null - })); - if (restored.length > 0) { - setTracks(restored); - showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success"); - } - } catch (e) { - showToast("Lỗi tải dự án từ .sfs", "error"); - } finally { - window.__pendingSfsProject = null; - } - }; - const handleAuthSuccess = user => { - setCurrentUser(user); - if (user.must_change_password) { - setIsMandatoryLogin(true); - setAuthMode('force_change'); - setAuthModalOpen(true); - } else { - setIsMandatoryLogin(false); - setAuthModalOpen(false); - loadPendingSfsProject(); - const loadPrefs = p => { - if (!p) return; - if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); - if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); - if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); - if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); - if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); - if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); - if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); - if (p.panelPositions) setPanelPositions(p.panelPositions); - if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); - if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); - if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); - }; - (async () => { - try { - const data = await window.SonicAPI.getPreferences(); - if (data && data.preferences) loadPrefs(data.preferences);else { - const cached = localStorage.getItem('sonic_preferences'); - if (cached) loadPrefs(JSON.parse(cached)); - } - } catch (e) { - const cached = localStorage.getItem('sonic_preferences'); - if (cached) loadPrefs(JSON.parse(cached)); - } - try { - const data = await window.SonicAPI.getAIConfigs(); - if (data && data.providers) { - setAiProviders(data.providers); - const active = data.providers.find(p => p.is_active) || data.providers[0]; - if (active) setSelectedProviderId(active.id); - } - } catch (e) {} - })(); - } - }; - const handleLogout = () => { - localStorage.removeItem('sonic_token'); - localStorage.removeItem('sonic_user'); - setCurrentUser(null); - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); - }; - - // ── Temp project auto-save (local + server) ── - useEffect(() => { - const serializeSafe = arr => (arr || []).map(t => ({ - id: t.id, - name: t.name, - startTime: t.startTime, - height: t.height, - volumeDb: t.volumeDb, - pan: t.pan, - muted: t.muted, - solo: t.solo, - color: t.color, - markers: t.markers || [], - serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { - channels: t.channelInfo.channels, - isStereo: t.channelInfo.isStereo, - label: t.channelInfo.label - } : null - })); - window.SonicStorage.scheduleTempAutoSave(() => ({ - id: 'temp_project', - name: 'Dự án tạm chưa lưu', - tracks: serializeSafe(tracks), - subTabs: (subTabs || []).map(s => ({ - id: s.id, - label: s.label, - trackId: s.trackId, - clipId: s.clipId, - startTime: s.startTime, - endTime: s.endTime, - speed: s.speed, - fadeInLen: s.fadeInLen || 0, - fadeOutLen: s.fadeOutLen || 0, - graphMode: s.graphMode, - volumeNodes: s.volumeNodes || [], - panningNodes: s.panningNodes || [], - channelInfo: s.channelInfo ? { - channels: s.channelInfo.channels, - isStereo: s.channelInfo.isStereo, - label: s.channelInfo.label - } : null - })) - })); - }, [tracks, subTabs]); - - // Lucide icons initialization - useEffect(() => { - setTimeout(() => { - if (window.lucide) { - window.lucide.createIcons(); - } - }, 50); - }, [activeTool, activeTab]); - const timelineWrapperRef = useRef(null); - const tcpContainerRef = useRef(null); - const [scrollLeft, setScrollLeft] = useState(0); - const handleTimelineScroll = e => { - if (tcpContainerRef.current) { - tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop; - } - setScrollLeft(e.currentTarget.scrollLeft); - }; - const handleTCPScroll = e => { - if (timelineWrapperRef.current) { - timelineWrapperRef.current.scrollTop = e.currentTarget.scrollTop; - } - }; - const panelDropZoneRef = useRef(null); - const startPanelDrag = (panelId, e) => { - panelDragRef.current = { - panelId, - startX: e.clientX, - startY: e.clientY - }; - setPanelDropZone(null); - setDragGhostPanel(panelId); - setDragGhostPos({ - x: e.clientX - 120, - y: e.clientY - 20 - }); - const onMove = ev => { - if (!panelDragRef.current || !workspaceRef.current) return; - const rect = workspaceRef.current.getBoundingClientRect(); - const x = ev.clientX - rect.left; - const y = ev.clientY - rect.top; - const w = rect.width; - const h = rect.height; - const margin = 60; - let zone = null; - if (x < margin && x > 10) zone = 'left';else if (x > w - margin && x < w - 10) zone = 'right';else if (y < margin && y > 10) zone = 'top';else if (y > h - margin && y < h - 10) zone = 'bottom'; - panelDropZoneRef.current = zone; - setPanelDropZone(zone); - setDragGhostPos({ - x: ev.clientX - 120, - y: ev.clientY - 20 - }); - }; - const onUp = () => { - if (panelDragRef.current) { - const pid = panelDragRef.current.panelId; - const targetZone = panelDropZoneRef.current; - if (targetZone && targetZone !== panelPositions[pid]) { - setPanelPositions(prev => ({ - ...prev, - [pid]: targetZone - })); - } - panelDragRef.current = null; - panelDropZoneRef.current = null; - setPanelDropZone(null); - setDragGhostPos(null); - setDragGhostPanel(null); - } - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }; - - // ── Right Sidebar Column Resizer ── - const startColResize = e => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = rightSidebarWidth; - const onMove = ev => { - const deltaX = startX - ev.clientX; - const newWidth = Math.max(200, Math.min(600, startWidth + deltaX)); - setRightSidebarWidth(newWidth); - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }; - - // ── Right Sidebar Row Resizer (Media Explorer / AI Panel) ── - const startRowResize = e => { - e.preventDefault(); - const startY = e.clientY; - const startHeight = mediaExplorerHeight; - const sidebarEl = document.getElementById('right-sidebar'); - const sidebarHeight = sidebarEl ? sidebarEl.getBoundingClientRect().height : 400; - const onMove = ev => { - const deltaY = startY - ev.clientY; - const pct = (startHeight / 100 * sidebarHeight + deltaY) / sidebarHeight * 100; - const newPct = Math.max(20, Math.min(80, pct)); - setMediaExplorerHeight(newPct); - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }; - const rulerRef = useRef(null); - const activeSourcesRef = useRef([]); - const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } } - const startOffsetTimeRef = useRef(0); - const startBufferOffsetRef = useRef(0); - const startAudioTimeRef = useRef(0); - const animationFrameIdRef = useRef(null); - const toastTimeoutRef = useRef(null); - const rulerDragStartRef = useRef(null); - const rulerAnchorRef = useRef(null); - const isDraggingRulerRef = useRef(false); - const handlePlayPauseRef = useRef(null); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; - - // ── Keyboard Shortcuts ── - const handleUndoRef = useRef(handleUndo); - const handleRedoRef = useRef(handleRedo); - handleUndoRef.current = handleUndo; - handleRedoRef.current = handleRedo; - const selectedClipIdRef = useRef(null); - selectedClipIdRef.current = selectedClipId; - const activeTabRef = useRef(activeTab); - activeTabRef.current = activeTab; - const activePlaybackSpeedRef = useRef(1.0); - const subTabsRef = useRef(subTabs); - subTabsRef.current = subTabs; - const subTabSelectedNodeTimeRef = useRef(null); - subTabSelectedNodeTimeRef.current = subTabSelectedNodeTime; - const handleSubTabNormalize = tabId => { - setSubTabs(prev => prev.map(st => { - if (st.id !== tabId || !st.buffer) return st; - const ctx = getAudioContext(); - const data = st.buffer.getChannelData(0); - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; - const startSample = Math.floor(left * st.buffer.sampleRate); - const endSample = Math.floor(right * st.buffer.sampleRate); - let maxVal = 0; - for (let i = startSample; i < endSample; i++) { - const abs = Math.abs(data[i]); - if (abs > maxVal) maxVal = abs; - } - if (maxVal === 0) return st; - const scale = 1.0 / maxVal; - const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); - const clonedData = clonedBuffer.getChannelData(0); - clonedData.set(data); - for (let i = startSample; i < endSample; i++) { - clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); - } - showToast('Đã Normalize vùng chọn.', 'success'); - return { - ...st, - buffer: clonedBuffer - }; - })); - }; - const handleSubTabGain = (tabId, gainDb) => { - setSubTabs(prev => prev.map(st => { - if (st.id !== tabId || !st.buffer) return st; - const ctx = getAudioContext(); - const data = st.buffer.getChannelData(0); - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; - const startSample = Math.floor(left * st.buffer.sampleRate); - const endSample = Math.floor(right * st.buffer.sampleRate); - const scale = Math.pow(10, gainDb / 20); - const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); - const clonedData = clonedBuffer.getChannelData(0); - clonedData.set(data); - for (let i = startSample; i < endSample; i++) { - clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); - } - showToast(`Đã điều chỉnh Gain: ${gainDb} dB.`, 'success'); - return { - ...st, - buffer: clonedBuffer - }; - })); - }; - const handleSubTabFade = (tabId, type) => { - setSubTabs(prev => prev.map(st => { - if (st.id !== tabId || !st.buffer) return st; - const ctx = getAudioContext(); - const data = st.buffer.getChannelData(0); - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; - const startSample = Math.floor(left * st.buffer.sampleRate); - const endSample = Math.floor(right * st.buffer.sampleRate); - const durationSamples = endSample - startSample; - if (durationSamples <= 0) return st; - const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); - const clonedData = clonedBuffer.getChannelData(0); - clonedData.set(data); - if (type === 'in') { - for (let i = 0; i < durationSamples; i++) { - const alpha = i / durationSamples; - clonedData[startSample + i] *= alpha; - } - showToast('Đã áp dụng Fade In.', 'success'); - } else if (type === 'out') { - for (let i = 0; i < durationSamples; i++) { - const alpha = (durationSamples - i) / durationSamples; - clonedData[startSample + i] *= alpha; - } - showToast('Đã áp dụng Fade Out.', 'success'); - } - return { - ...st, - buffer: clonedBuffer - }; - })); - }; - const handleSubTabCut = tabId => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Cut.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - const cutBuffer = ctx.createBuffer(1, len, sr); - cutBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - clipboardRef.current = { - buffer: cutBuffer, - name: 'Subtab Clip' - }; - const newBuffer = ctx.createBuffer(1, data.length - len, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - currentTime: left, - selectionStart: null, - selectionEnd: null - } : s)); - showToast('Đã Cut vùng chọn.', 'success'); - }; - const handleSubTabCopy = tabId => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Copy.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - const copyBuffer = ctx.createBuffer(1, len, sr); - copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - clipboardRef.current = { - buffer: copyBuffer, - name: 'Subtab Clip', - sampleRate: sr, - channels: 1, - speed: 1.0 - }; - showToast('Đã Copy vùng chọn.', 'success'); - }; - const handleSubTabPaste = tabId => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - if (!clipboardRef.current || !clipboardRef.current.buffer) { - showToast('Clipboard trống.', 'warning'); - return; - } - const ctx = getAudioContext(); - const clipBuf = clipboardRef.current.buffer; - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const insertTime = st.currentTime || 0; - const insertSample = Math.floor(insertTime * sr); - const newBuffer = ctx.createBuffer(1, data.length + clipBuf.length, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < insertSample; i++) newData[idx++] = data[i]; - const clipData = clipBuf.getChannelData(0); - for (let i = 0; i < clipBuf.length; i++) newData[idx++] = clipData[i]; - for (let i = insertSample; i < data.length; i++) newData[idx++] = data[i]; - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - currentTime: insertTime + clipBuf.duration, - selectionStart: null, - selectionEnd: null - } : s)); - showToast('Đã dán dữ liệu âm thanh.', 'success'); - }; - const handleSubTabDelete = tabId => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Delete.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - const newBuffer = ctx.createBuffer(1, data.length - len, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - currentTime: left, - selectionStart: null, - selectionEnd: null - } : s)); - showToast('Đã xóa vùng chọn.', 'success'); - }; - const handleSubTabLoop = (tabId, loopCount) => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Loop.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - - // Loop payload N times - const segmentData = data.subarray(startSample, endSample); - const addedSamples = len * (loopCount - 1); - const newBuffer = ctx.createBuffer(1, data.length + addedSamples, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < endSample; i++) newData[idx++] = data[i]; - for (let n = 0; n < loopCount - 1; n++) { - for (let i = 0; i < len; i++) newData[idx++] = segmentData[i]; - } - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - selectionStart: null, - selectionEnd: null - } : s)); - showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success'); - }; - useEffect(() => { - const handler = e => { - // Bypass global hotkeys when typing inside input/textarea/contentEditable elements - if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable)) { - return; - } - const ctrl = e.ctrlKey || e.metaKey; - const alt = e.altKey; - - // Global space play/pause shortcut for transport - if (e.key === ' ' || e.code === 'Space') { - e.preventDefault(); - if (handlePlayPauseRef.current) handlePlayPauseRef.current(); - return; - } - if (activeTabRef.current !== 'main') { - // Sub-Tab keyboard shortcuts mapping - const curTabId = activeTabRef.current; - if (ctrl && alt && e.key === 'n') { - e.preventDefault(); - handleSubTabNormalize(curTabId); - return; - } - if (e.key === 'f' || e.key === 'F') { - e.preventDefault(); - handleSubTabFade(curTabId, 'in'); - return; - } - if (e.key === 'g' || e.key === 'G') { - e.preventDefault(); - handleSubTabFade(curTabId, 'out'); - return; - } - if (ctrl && e.key === 'l') { - e.preventDefault(); - handleSubTabLoop(curTabId, 4); - return; - } - if (e.key === 'v' || e.key === 'V') { - e.preventDefault(); - const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); - if (val) handleSubTabGain(curTabId, parseFloat(val) || 0); - return; - } - if (ctrl && e.key === 'x') { - e.preventDefault(); - handleSubTabCut(curTabId); - return; - } - if (ctrl && e.key === 'c') { - e.preventDefault(); - handleSubTabCopy(curTabId); - return; - } - if (ctrl && e.key === 'v') { - e.preventDefault(); - handleSubTabPaste(curTabId); - return; - } - if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { - e.preventDefault(); - if (subTabSelectedNodeTimeRef.current !== null) { - const selTime = subTabSelectedNodeTimeRef.current; - setSubTabs(prev => prev.map(s => { - if (s.id !== curTabId) return s; - const curNodes = s.graphMode === 'pan' ? s.panningNodes || [] : s.volumeNodes || []; - const updated = curNodes.filter(n => n.time !== selTime); - return { - ...s, - [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: updated - }; - })); - setSubTabSelectedNodeTime(null); - } else { - handleSubTabDelete(curTabId); - } - return; - } - return; - } - if (ctrl && e.key === 'z' && !e.shiftKey) { - e.preventDefault(); - handleUndoRef.current(); - return; - } - if (ctrl && (e.key === 'y' || e.key === 'z' && e.shiftKey)) { - e.preventDefault(); - handleRedoRef.current(); - return; - } - if (ctrl && !alt && e.key === 'o') { - e.preventDefault(); - handleImportSFS(); - return; - } - if (ctrl && !alt && e.key === 'n') { - e.preventDefault(); - setTracks([{ - id: '1', - name: 'Track 01', - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: '#0f766e', - markers: [], - serverFileId: null, - clips: [], - sections: [], - midiItems: [] - }, { - id: '2', - name: 'Track 02', - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: '#1d4ed8', - markers: [], - serverFileId: null, - clips: [], - sections: [], - midiItems: [] - }]); - return; - } - if (ctrl && !alt && e.key === 's') { - e.preventDefault(); - handleExportSFS(); - return; - } - if (ctrl && alt && e.key === 's' || ctrl && e.shiftKey && e.key === 's') { - e.preventDefault(); - handleExportSFS(); - return; - } - if (ctrl && !alt && e.key === 'i') { - e.preventDefault(); - addNewTrack(); - return; - } - if (ctrl && alt && e.key === 'i') { - e.preventDefault(); - showToast('Import audio', 'info'); - return; - } - if (ctrl && !alt && e.key === 'e') { - e.preventDefault(); - openTempTab(); - return; - } - if (ctrl && !alt && e.key === 'm') { - e.preventDefault(); - handleMergeTracks(); - return; - } - if (ctrl && !alt && e.key === 'c') { - e.preventDefault(); - handleCopyTrack(); - return; - } - if (ctrl && !alt && e.key === 'x') { - e.preventDefault(); - handleCutTrack(); - return; - } - if (ctrl && !alt && e.key === 'v') { - e.preventDefault(); - handlePasteTrack(); - return; - } - if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { - const selClip = selectedClipIdRef.current; - if (selClip) { - e.preventDefault(); - const { - trackId, - clipId - } = selClip; - setTracks(prev => { - const track = prev.find(t => t.id === trackId); - if (!track) return prev; - const beforeSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; - const updatedClips = (track.clips || []).filter(c => c.id !== clipId); - const updatedTracks = prev.map(t => { - if (t.id === trackId) { - return { - ...t, - clips: updatedClips, - buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, - startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, - name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` - }; - } - return t; - }); - setTimeout(() => { - const afterSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; - pushAction('DELETE_CLIP', trackId, beforeSnap, afterSnap); - }, 50); - return updatedTracks; - }); - setSelectedClipId(null); - showToast('Đã xóa clip.', 'info'); - return; - } else { - e.preventDefault(); - handleDeleteTrack(); - return; - } - } - if (ctrl && !alt && e.key === 's') { - e.preventDefault(); - handleSaveProject(); - return; - } - if (!ctrl && !alt && e.key === 's') { - e.preventDefault(); - handleSplitTrack(selectedTrackId); - return; - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, []); - - // ── Temp Tab: draw isolated waveform ── - useEffect(() => { - if (!tempTabActive || !tempTabBuffer || !tempTabCanvasRef.current) return; - const canvas = tempTabCanvasRef.current; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const w = rect.width; - const h = rect.height; - ctx.fillStyle = '#181818'; - ctx.fillRect(0, 0, w, h); - const data = tempTabBuffer.getChannelData(0); - const sr = tempTabBuffer.sampleRate; - const totalSamples = data.length; - if (totalSamples === 0) return; - ctx.strokeStyle = '#6ee7b7'; - ctx.lineWidth = 1; - for (let px = 0; px < w; px++) { - const startSample = Math.floor(px / w * totalSamples); - const endSample = Math.floor((px + 1) / w * totalSamples); - let maxVal = 0; - for (let i = startSample; i < endSample && i < totalSamples; i++) { - const abs = Math.abs(data[i]); - if (abs > maxVal) maxVal = abs; - } - const mid = h / 2; - const peakHeight = maxVal * (h * 0.4); - ctx.beginPath(); - ctx.moveTo(px, mid - peakHeight); - ctx.lineTo(px, mid + peakHeight); - ctx.stroke(); - } - }, [tempTabActive, tempTabBuffer]); - - // ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ── - const openTempTab = () => { - const useLocal = selectionMode === 'local' && localSelectionTrackId; - const trackId = useLocal ? localSelectionTrackId : selectedTrackId; - const t = tracks.find(x => x.id === trackId); - if (!t || !t.buffer) { - showToast('Vui lòng chọn track có dữ liệu âm thanh.', 'warning'); - return; - } - if (selLeft === null || selRight === null || selRight <= selLeft) { - showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning'); - return; - } - - // Check if a subtab for this track+range already exists - const existing = subTabs.find(s => s.trackId === trackId && s.startTime === selLeft && s.endTime === selRight); - if (existing) { - setActiveTab(existing.id); - showToast(`Sub Tab already open.`, 'info'); - return; - } - const sr = t.buffer.sampleRate; - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.max(0, Math.floor(relSelLeft * sr)); - const endSample = Math.min(t.buffer.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len < 100) { - showToast('Khoảng chọn quá ngắn.', 'warning'); - return; - } - const ctx = getAudioContext(); - const numChannels = t.buffer.numberOfChannels || 1; - const subBuffer = ctx.createBuffer(numChannels, len, sr); - for (let c = 0; c < numChannels; c++) { - subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample, endSample), c); - } - const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); - const tabId = 'subtab_' + Date.now(); - const tabLabel = `Edit_${t.name.replace('.wav', '').slice(0, 10)}_${selLeft.toFixed(1)}s`; - setSubTabs(prev => [...prev, { - id: tabId, - label: tabLabel, - trackId: trackId, - startTime: selLeft, - endTime: selRight, - buffer: subBuffer, - channelInfo: subChannelInfo, - effects: { - reverse: false, - gainDb: 0, - fadeInMs: 0, - fadeOutMs: 0, - normalizeDb: 0, - pitch: 0, - speedStretch: 100 - }, - currentTime: 0, - selectionStart: null, - selectionEnd: null, - isPlaying: false, - speed: 1.0, - volumeNodes: [], - panningNodes: [], - fadeInLen: 0, - fadeOutLen: 0, - graphMode: null, - isLooping: false, - loopCount: 0 - }]); - setActiveTab(tabId); - }; - const handleEditClipInSubTab = (trackId, clipId) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []; - const clip = clips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + trackId); - if (!clip || !clip.buffer) return; - const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id; - const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId); - if (existing) { - setActiveTab(existing.id); - showToast(`Sub Tab for "${clip.name}" already open.`, 'info'); - return; - } - const sr = clip.buffer.sampleRate; - const len = clip.buffer.length; - const numChannels = clip.buffer.numberOfChannels || 1; - const ctx = getAudioContext(); - const subBuffer = ctx.createBuffer(numChannels, len, sr); - for (let c = 0; c < numChannels; c++) { - subBuffer.copyToChannel(clip.buffer.getChannelData(c), c); - } - const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); - const tabId = 'subtab_' + Date.now(); - const tabLabel = `Edit_${clip.name.replace('.wav', '').slice(0, 10)}`; - setSubTabs(prev => [...prev, { - id: tabId, - label: tabLabel, - trackId: trackId, - clipId: resolvedClipId, - startTime: clip.startTime, - endTime: clip.startTime + clip.buffer.duration, - buffer: subBuffer, - channelInfo: subChannelInfo, - effects: { - reverse: false, - gainDb: 0, - fadeInMs: 0, - fadeOutMs: 0, - normalizeDb: 0, - pitch: 0, - speedStretch: 100 - }, - currentTime: 0, - selectionStart: null, - selectionEnd: null, - isPlaying: false, - speed: 1.0, - volumeNodes: [], - panningNodes: [], - fadeInLen: 0, - fadeOutLen: 0, - graphMode: null, - isLooping: false, - loopCount: 0 - }]); - setActiveTab(tabId); - }; - - // ── Double-click/Edit Section: open Main Session in new tab ── - const handleEditSectionInTab = (trackId, sectionId) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - const section = (track.sections || []).find(s => s.id === sectionId); - if (!section) return; - const existing = sessionTabs.find(s => s.sectionId === sectionId); - if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; } - const tabId = 'session_' + Date.now(); - const tabName = section.name || 'Section'; - const clonedTracks = tracks.map(t => ({ - ...t, - clips: [], - sections: [], - midiItems: [], - markers: [] - })); - setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]); - setActiveTab(tabId); - }; - - // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── - const applySubTab = tabId => { - const subTab = subTabs.find(s => s.id === tabId); - if (!subTab || !subTab.buffer) return; - const track = tracks.find(t => t.id === subTab.trackId); - if (!track || !track.buffer) return; - const beforeSnap = captureTrackSnapshot(subTab.trackId); - - // Clone buffer and apply effects - const ctx = getAudioContext(); - const sr = subTab.buffer.sampleRate; - const eff = subTab.buffer.getChannelData(0); - let resultBuffer = ctx.createBuffer(1, eff.length, sr); - let resultData = resultBuffer.getChannelData(0); - resultData.set(eff); - - // Apply effects inline - const fx = subTab.effects || {}; - const applyResample = (data, ratio) => { - const newLen = Math.round(data.length * ratio); - const out = new Float32Array(newLen); - for (let i = 0; i < newLen; i++) { - const srcIdx = i / ratio; - const idx0 = Math.floor(srcIdx); - const idx1 = Math.min(idx0 + 1, data.length - 1); - const frac = srcIdx - idx0; - out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; - } - return out; - }; - if (fx.reverse) { - const rev = new Float32Array(resultData); - for (let i = 0; i < resultData.length; i++) rev[i] = resultData[resultData.length - 1 - i]; - resultData.set(rev); - } - if (fx.gainDb !== 0) { - const gain = Math.pow(10, fx.gainDb / 20); - for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gain)); - } - if (fx.fadeInMs > 0) { - const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr)); - for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples; - } - // Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3) - const gFadeIn = subTab.fadeInLen || 0; - const gFadeOut = subTab.fadeOutLen || 0; - if (gFadeIn > 0) { - const fadeSamples = Math.min(resultData.length, Math.floor(gFadeIn * sr)); - for (let i = 0; i < fadeSamples; i++) { - resultData[i] *= (1 - Math.cos(Math.PI * i / fadeSamples)) / 2; - } - } - if (gFadeOut > 0) { - const fadeSamples = Math.min(resultData.length, Math.floor(gFadeOut * sr)); - for (let i = resultData.length - fadeSamples; i < resultData.length; i++) { - const t = i - (resultData.length - fadeSamples); - resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2; - } - } - - // Graph Editor volume automation spline (Monotone Cubic Hermite Spline, 16_FIX_GRAPH.md §2.1) - const volNodes = subTab.volumeNodes || []; - if (volNodes.length > 0) { - const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); - const computeHermiteTangents = pts => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i - 1].time; - const hN = pts[i + 1].time - pts[i].time; - const sP = (pts[i].db - pts[i - 1].db) / hP; - const sN = (pts[i + 1].db - pts[i].db) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; - m[n - 1] = n > 1 ? (pts[n - 1].db - pts[n - 2].db) / (pts[n - 1].time - pts[n - 2].time) : 0; - return m; - }; - const tangents = computeHermiteTangents(sortedNodes); - const getVolumeGainAtTime = t => { - if (sortedNodes.length === 1) { - return Math.pow(10, sortedNodes[0].db / 20); - } - if (t <= sortedNodes[0].time) { - return Math.pow(10, sortedNodes[0].db / 20); - } - if (t >= sortedNodes[sortedNodes.length - 1].time) { - return Math.pow(10, sortedNodes[sortedNodes.length - 1].db / 20); - } - for (let i = 0; i < sortedNodes.length - 1; i++) { - const n1 = sortedNodes[i]; - const n2 = sortedNodes[i + 1]; - if (t >= n1.time && t <= n2.time) { - const h = n2.time - n1.time; - if (h <= 0) return Math.pow(10, n1.db / 20); - const frac = (t - n1.time) / h; - const frac2 = frac * frac, - frac3 = frac2 * frac; - const db = (2 * frac3 - 3 * frac2 + 1) * n1.db + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.db + (frac3 - frac2) * h * tangents[i + 1]; - return Math.pow(10, db / 20); - } - } - return 1.0; - }; - for (let i = 0; i < resultData.length; i++) { - const t = i / sr; - resultData[i] *= getVolumeGainAtTime(t); - } - } - if (fx.normalizeDb !== 0) { - let maxVal = 0; - for (let i = 0; i < resultData.length; i++) { - const abs = Math.abs(resultData[i]); - if (abs > maxVal) maxVal = abs; - } - if (maxVal > 0) { - const targetAmp = Math.pow(10, fx.normalizeDb / 20); - const scale = targetAmp / maxVal; - for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); - } - } - if (fx.pitch !== 0) { - const ratio = Math.pow(2, fx.pitch / 12); - const newData = applyResample(resultData, 1 / ratio); - resultBuffer = ctx.createBuffer(1, newData.length, sr); - resultData = resultBuffer.getChannelData(0); - resultData.set(newData); - } - const stretchSpeed = subTab.speed || 1.0; - if (stretchSpeed !== 1.0) { - const ratio = 1.0 / stretchSpeed; - const newData = applyResample(resultData, ratio); - resultBuffer = ctx.createBuffer(1, newData.length, sr); - resultData = resultBuffer.getChannelData(0); - resultData.set(newData); - } - if (fx.speedStretch !== 100) { - const ratio = fx.speedStretch / 100; - const newData = applyResample(resultData, ratio); - resultBuffer = ctx.createBuffer(1, newData.length, sr); - resultData = resultBuffer.getChannelData(0); - resultData.set(newData); - } - if (subTab.clipId) { - // Clip-based merge - setTracks(prev => prev.map(t => { - if (t.id !== subTab.trackId) return t; - const updatedClips = (t.clips || []).map(c => { - if (c.id === subTab.clipId) { - return { - ...c, - buffer: resultBuffer, - name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' - }; - } - return c; - }); - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer, - startTime: updatedClips[0]?.startTime || 0, - name: updatedClips[0]?.name || t.name - }; - })); - } else { - // Crossfade merge into original track (§2.2) - const sr = track.buffer.sampleRate; - const origData = track.buffer.getChannelData(0); - const trackStart = track.startTime || 0; - const startSample = Math.floor((subTab.startTime - trackStart) * sr); - const endSample = Math.floor((subTab.endTime - trackStart) * sr); - const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms - - const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); - const mergedData = mergedBuffer.getChannelData(0); - for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; - for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; - for (let i = 0; i < resultData.length; i++) { - const globalIdx = startSample + i; - let val = resultData[i]; - if (i < crossfadeLen) { - const alpha = i / crossfadeLen; - val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i]; - } else if (i > resultData.length - crossfadeLen) { - const distFromEnd = resultData.length - 1 - i; - const alpha = distFromEnd / crossfadeLen; - const origEndIdx = endSample - (resultData.length - i); - val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i]; - } - mergedData[globalIdx] = val; - } - setTracks(prev => prev.map(t => { - if (t.id !== subTab.trackId) return t; - return { - ...t, - buffer: mergedBuffer, - name: t.name + ' (edited)' - }; - })); - } - const afterSnap = captureTrackSnapshot(subTab.trackId); - pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap); - - // Tab Lifetime: Engaging the Apply trigger propagates data back to the primary environment but does not close down the active sub-tab view. - // closeSubTab(tabId); - showToast('Đã áp dụng chỉnh sửa vào track chính.', 'success'); - }; - const applySubTabEffect = (tabId, effectType, value) => { - const subTab = subTabs.find(s => s.id === tabId); - if (!subTab || !subTab.buffer) return; - const ctx = getAudioContext(); - const sr = subTab.buffer.sampleRate; - const eff = subTab.buffer.getChannelData(0); - - // Clone buffer - let resultBuffer = ctx.createBuffer(1, eff.length, sr); - let resultData = resultBuffer.getChannelData(0); - resultData.set(eff); - - // Calculate selection start/end in unstretched sample indices - const speed = subTab.speed || 1.0; - const hasSelection = subTab.selectionStart !== null && subTab.selectionEnd !== null && subTab.selectionStart !== subTab.selectionEnd; - const tStart = hasSelection ? Math.min(subTab.selectionStart, subTab.selectionEnd) * speed : 0; - const tEnd = hasSelection ? Math.max(subTab.selectionStart, subTab.selectionEnd) * speed : subTab.buffer.duration; - const startSample = Math.max(0, Math.min(eff.length, Math.floor(tStart * sr))); - const endSample = Math.max(0, Math.min(eff.length, Math.floor(tEnd * sr))); - if (effectType === 'normalize') { - let maxVal = 0; - for (let i = startSample; i < endSample; i++) { - const abs = Math.abs(resultData[i]); - if (abs > maxVal) maxVal = abs; - } - if (maxVal > 0) { - const targetAmp = Math.pow(10, value / 20); - const scale = targetAmp / maxVal; - for (let i = startSample; i < endSample; i++) { - resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); - } - } - } else if (effectType === 'gain') { - const gainScale = value / 100; - for (let i = startSample; i < endSample; i++) { - resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gainScale)); - } - } else if (effectType === 'pitch') { - const ratio = Math.pow(2, value / 12); - const applyResample = (data, r) => { - const newLen = Math.round(data.length * r); - const out = new Float32Array(newLen); - for (let i = 0; i < newLen; i++) { - const srcIdx = i / r; - const idx0 = Math.floor(srcIdx); - const idx1 = Math.min(idx0 + 1, data.length - 1); - const frac = srcIdx - idx0; - out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; - } - return out; - }; - const sub = resultData.slice(startSample, endSample); - const subResampled = applyResample(sub, 1 / ratio); - for (let i = 0; i < endSample - startSample; i++) { - resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0; - } - } - - // Update subTab buffer state - setSubTabs(prev => prev.map(s => { - if (s.id !== tabId) return s; - return { - ...s, - buffer: resultBuffer, - selectionStart: null, - selectionEnd: null - }; - })); - showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success'); - }; - const exportSubTabBuffer = async tabId => { - const subTab = subTabs.find(s => s.id === tabId); - if (!subTab || !subTab.buffer) return; - try { - const buffer = subTab.buffer; - const sr = buffer.sampleRate; - const monoData = buffer.getChannelData(0); - const bufferLength = monoData.length; - const bitDepth = 16; - const bytesPerSample = 2; - const headerSize = 44; - const fileSizeBytes = headerSize + bufferLength * bytesPerSample; - const fileBuffer = new ArrayBuffer(fileSizeBytes); - const view = new DataView(fileBuffer); - const writeString = (offset, string) => { - for (let i = 0; i < string.length; i++) { - view.setUint8(offset + i, string.charCodeAt(i)); - } - }; - writeString(0, 'RIFF'); - view.setUint32(4, fileSizeBytes - 8, true); - writeString(8, 'WAVE'); - writeString(12, 'fmt '); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, 1, true); - view.setUint32(24, sr, true); - view.setUint32(28, sr * bytesPerSample, true); - view.setUint16(32, bytesPerSample, true); - view.setUint16(34, bitDepth, true); - writeString(36, 'data'); - view.setUint32(40, bufferLength * bytesPerSample, true); - let offset = 44; - for (let i = 0; i < bufferLength; i++) { - const sample = Math.max(-1, Math.min(1, monoData[i])); - view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); - offset += bytesPerSample; - } - const blob = new Blob([view], { - type: 'audio/wav' - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${subTab.label || 'subtab-export'}.wav`; - a.click(); - URL.revokeObjectURL(url); - showToast("Xuất bản sub-tab hoàn tất!", "success"); - } catch (err) { - showToast("Lỗi xuất âm thanh: " + err.message, "error"); - } - }; - const closeSubTab = tabId => { - setSubTabs(prev => prev.filter(s => s.id !== tabId)); - if (activeTab === tabId) setActiveTab('main'); - }; - const closeSessionTab = tabId => { - setSessionTabs(prev => prev.filter(s => s.id !== tabId)); - if (activeTab === tabId) setActiveTab('main'); - }; - const updateSubTabEffects = (tabId, effects) => { - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - effects: { - ...s.effects, - ...effects - } - } : s)); - }; - const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => { - updateSubTabEffects(tabId, { - normalizeDb - }); - }; - const handleSubTabGainWithValue = (tabId, gainDb) => { - updateSubTabEffects(tabId, { - gainDb - }); - }; - const handleSubTabPitch = (tabId, pitch) => { - updateSubTabEffects(tabId, { - pitch - }); - }; - const handleSubTabStretch = (tabId, speedStretch) => { - updateSubTabEffects(tabId, { - speedStretch - }); - }; - const handleSubTabFadeState = (tabId, type) => { - const st = subTabs.find(s => s.id === tabId); - if (!st) return; - const fx = st.effects || {}; - if (type === 'in') { - updateSubTabEffects(tabId, { - fadeInMs: (fx.fadeInMs || 0) + 10 - }); - } else if (type === 'out') { - updateSubTabEffects(tabId, { - fadeOutMs: (fx.fadeOutMs || 0) + 10 - }); - } - }; - - // ── Context Menu Handlers ── - const handleContextMenu = (e, trackId, clickTime, sectionId) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ - x: e.clientX, - y: e.clientY, - trackId, - time: clickTime || currentTime, - sectionId: sectionId || null - }); - }; - const closeContextMenu = () => setContextMenu(null); - - const contextMenuEditSection = () => { - if (contextMenu.sectionId) { - handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId); - } - closeContextMenu(); - }; - - // Close context menu on any click outside - useEffect(() => { - const handler = () => { - if (contextMenu) closeContextMenu(); - }; - if (contextMenu) { - window.addEventListener('click', handler); - return () => window.removeEventListener('click', handler); - } - }, [contextMenu]); - const contextMenuEdit = () => { - const track = tracks.find(t => t.id === contextMenu.trackId); - if (track) setSelectedTrackId(contextMenu.trackId); - closeContextMenu(); - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - const clickedClip = clips.find(c => contextMenu.time >= c.startTime && contextMenu.time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - if (clickedClip) { - handleEditClipInSubTab(contextMenu.trackId, clickedClip.id); - } else { - openTempTab(); - } - }; - const contextMenuSplit = () => { - closeContextMenu(); - handleSplitTrack(contextMenu.trackId); - }; - const contextMenuDelete = () => { - const tid = contextMenu.trackId; - const sessionTab = sessionTabs.find(s => s.id === activeTab); - if (sessionTab) { - updateActiveTracks(prev => prev.filter(t => t.id !== tid)); - if (selectedTrackId === tid) setSelectedTrackId('1'); - closeContextMenu(); - showToast('Đã xoá track.', 'info'); - } else { - const track = tracks.find(t => t.id === tid); - if (track && track.sections && track.sections.length > 0) { - closeContextMenu(); - showToast('Không thể xoá track chứa Section item.', 'warning'); - return; - } - const beforeSnap = captureTrackSnapshot(tid); - setTracks(prev => prev.filter(t => t.id !== tid)); - const afterSnap = captureTrackSnapshot(tid); - pushAction('DELETE', tid, beforeSnap, afterSnap); - if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); - closeContextMenu(); - showToast('Đã xoá track.', 'info'); - } - }; - const contextMenuCopy = () => { - const track = tracks.find(t => t.id === contextMenu.trackId); - if (!track || !track.buffer) return; - const sr = track.buffer.sampleRate; - const data = track.buffer.getChannelData(0); - - // Copy selected region if selection exists - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = track.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - const ctx = getAudioContext(); - const numCh = track.buffer.numberOfChannels || 1; - const clipBuffer = ctx.createBuffer(numCh, len, sr); - for (let ch = 0; ch < numCh; ch++) { - clipBuffer.copyToChannel(track.buffer.getChannelData(ch).subarray(startSample, endSample), ch); - } - clipboardRef.current = { - buffer: clipBuffer, - name: track.name, - volumeDb: track.volumeDb, - pan: track.pan, - color: track.color, - sampleRate: clipBuffer.sampleRate, - channels: numCh, - speed: track.speed || 1.0 - }; - closeContextMenu(); - showToast('Đã sao chép vùng chọn.', 'info'); - return; - } - } - - // No selection: copy entire track - clipboardRef.current = { - buffer: track.buffer, - name: track.name, - volumeDb: track.volumeDb, - pan: track.pan, - color: track.color, - sampleRate: track.buffer.sampleRate, - channels: track.buffer.numberOfChannels, - speed: track.speed || 1.0 - }; - closeContextMenu(); - showToast('Đã sao chép toàn bộ track.', 'info'); - }; - const contextMenuCut = () => { - const t = tracks.find(x => x.id === contextMenu.trackId); - if (!t || !t.buffer) { - contextMenuDelete(); - return; - } - const beforeSnap = captureTrackSnapshot(contextMenu.trackId); - const sr = t.buffer.sampleRate; - const data = t.buffer.getChannelData(0); - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - const ctx = getAudioContext(); - const numCh = t.buffer.numberOfChannels || 1; - const clipBuffer = ctx.createBuffer(numCh, len, sr); - for (let ch = 0; ch < numCh; ch++) { - clipBuffer.copyToChannel(t.buffer.getChannelData(ch).subarray(startSample, endSample), ch); - } - clipboardRef.current = { - buffer: clipBuffer, - name: t.name, - volumeDb: t.volumeDb, - pan: t.pan, - color: t.color, - sampleRate: sr, - channels: numCh, - speed: t.speed || 1.0 - }; - const newLen = data.length - len; - const newBuffer = ctx.createBuffer(numCh, newLen, sr); - for (let ch = 0; ch < numCh; ch++) { - const src = t.buffer.getChannelData(ch); - const dst = newBuffer.getChannelData(ch); - let idx = 0; - for (let i = 0; i < startSample; i++) dst[idx++] = src[i]; - for (let i = endSample; i < src.length; i++) dst[idx++] = src[i]; - } - setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { - ...tr, - buffer: newBuffer - } : tr)); - const afterSnap = captureTrackSnapshot(contextMenu.trackId); - pushAction('CUT', contextMenu.trackId, beforeSnap, afterSnap); - closeContextMenu(); - showToast('Đã cắt vùng chọn vào clipboard.', 'info'); - return; - } - } - contextMenuCopy(); - contextMenuDelete(); - }; - const doPaste = (targetTrackId, pasteTime) => { - if (!clipboardRef.current || !clipboardRef.current.buffer) { - showToast('Clipboard trống.', 'warning'); - return null; - } - const { - buffer: clipBuffer, - name, - volumeDb, - pan, - color, - sampleRate, - channels, - speed - } = clipboardRef.current; - const ctx = getAudioContext(); - const targetTrack = tracks.find(t => t.id === targetTrackId); - const newClip = { - id: nextClipId(), - startTime: pasteTime, - buffer: clipBuffer, - name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)', - ...(volumeDb !== undefined ? { - volumeDb - } : {}), - ...(pan !== undefined ? { - pan - } : {}), - ...(color ? { - color - } : {}), - ...(speed !== undefined ? { - speed - } : {}) - }; - if (targetTrack) { - setTracks(p => p.map(t => { - if (t.id === targetTrackId) { - const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []; - const updatedClips = [...existingClips, newClip]; - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0].buffer, - startTime: updatedClips[0].startTime, - name: name || t.name, - volumeDb: volumeDb ?? t.volumeDb, - pan: pan ?? t.pan, - color: color || t.color - }; - } - return t; - })); - setCurrentTime(pasteTime); - showToast('Đã dán clip vào track.', 'success'); - return targetTrackId; - } - - // No matching track — create a new one - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const newId = 'track_pasted_' + Date.now(); - setTracks(prev => [...prev, { - id: newId, - name: `Pasted_${name || 'track'}`, - buffer: clipBuffer, - startTime: pasteTime, - clips: [newClip], - volumeDb: volumeDb ?? 0, - pan: pan ?? 0, - muted: false, - solo: false, - color: color || colors[prev.length % colors.length], - markers: [], - serverFileId: null - }]); - setSelectedTrackId(newId); - setCurrentTime(pasteTime); - showToast('Đã dán track mới từ clipboard.', 'success'); - return newId; - }; - const handlePasteTrack = () => doPaste(selectedTrackId, currentTime); - const contextMenuPaste = () => { - const result = doPaste(contextMenu.trackId, contextMenu.time || currentTime); - closeContextMenu(); - }; - const contextMenuMerge = () => { - const activeTracks = tracks.filter(t => t.buffer && !t.muted); - if (activeTracks.length < 2) { - showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning'); - closeContextMenu(); - return; - } - const ctx = getAudioContext(); - const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration)); - const sr = activeTracks[0].buffer.sampleRate; - const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr); - const mergedData = merged.getChannelData(0); - activeTracks.forEach(t => { - const data = t.buffer.getChannelData(0); - const startSample = Math.floor((t.startTime || 0) * sr); - const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20); - for (let i = 0; i < data.length; i++) { - if (startSample + i < mergedData.length) { - mergedData[startSample + i] += data[i] * volLinear; - } - } - }); - let maxPeak = 0; - for (let i = 0; i < mergedData.length; i++) { - const abs = Math.abs(mergedData[i]); - if (abs > maxPeak) maxPeak = abs; - } - if (maxPeak > 1.0) { - for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak; - } - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const newId = 'track_merged_' + Date.now(); - const names = activeTracks.map(t => t.name).join('+').slice(0, 30); - setTracks(prev => [...prev, { - id: newId, - name: `Merged_${names}.wav`, - buffer: merged, - startTime: 0, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: colors[prev.length % colors.length], - markers: [], - serverFileId: null - }]); - setSelectedTrackId(newId); - closeContextMenu(); - showToast(`Đã merge ${activeTracks.length} tracks.`, 'success'); - }; - - // Menu bar direct handlers (don't rely on contextMenu state) - const handleMergeTracks = () => { - const at = tracks.filter(t => t.buffer && !t.muted); - if (at.length < 2) { - showToast('Cần 2+ tracks để merge.', 'warning'); - return; - } - const actx = getAudioContext(); - const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration)); - const sr = at[0].buffer.sampleRate; - const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr); - const mdata = mb.getChannelData(0); - at.forEach(t => { - const d = t.buffer.getChannelData(0); - const startSample = Math.floor((t.startTime || 0) * sr); - const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20); - for (let i = 0; i < d.length; i++) { - if (startSample + i < mdata.length) { - mdata[startSample + i] += d[i] * volLinear; - } - } - }); - let mp = 0; - for (let i = 0; i < mdata.length; i++) { - const a = Math.abs(mdata[i]); - if (a > mp) mp = a; - } - if (mp > 1.0) for (let i = 0; i < mdata.length; i++) mdata[i] /= mp; - setTracks(p => [...p, { - id: 'merged_' + Date.now(), - name: 'Merged_mix.wav', - buffer: mb, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: ['#0f766e', '#1d4ed8'][p.length % 2], - markers: [], - serverFileId: null - }]); - showToast('Merged all unmuted tracks.', 'success'); - }; - const handleCopyTrack = () => { - const t = tracks.find(x => x.id === selectedTrackId); - if (!t || !t.buffer) return; - const sr = t.buffer.sampleRate; - const data = t.buffer.getChannelData(0); - // If selection exists, copy only the selected region - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - clipboardRef.current = { - buffer: clipBuffer, - name: t.name, - volumeDb: t.volumeDb, - color: t.color - }; - showToast('Copied selection to clipboard.', 'info'); - return; - } - } - // No selection: copy entire track - clipboardRef.current = { - buffer: clipBuffer, - name: t.name, - volumeDb: t.volumeDb, - pan: t.pan, - color: t.color, - sampleRate: clipBuffer.sampleRate, - channels: clipBuffer.numberOfChannels, - speed: t.speed || 1.0 - }; - showToast('Copied track to clipboard.', 'info'); - }; - const handleCutTrack = () => { - const t = tracks.find(x => x.id === selectedTrackId); - if (!t || !t.buffer) return; - const beforeSnap = captureTrackSnapshot(selectedTrackId); - const sr = t.buffer.sampleRate; - const data = t.buffer.getChannelData(0); - // If selection exists, cut only the selected region - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - // Copy selection to clipboard - const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - clipboardRef.current = { - buffer: clipBuffer, - name: t.name, - volumeDb: t.volumeDb, - color: t.color - }; - // Remove selection from track, glue the two remaining parts - const newLen = data.length - len; - const newBuffer = ctx.createBuffer(1, newLen, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - setTracks(p => p.map(tr => tr.id === selectedTrackId ? { - ...tr, - buffer: newBuffer - } : tr)); - const afterSnap = captureTrackSnapshot(selectedTrackId); - pushAction('CUT', selectedTrackId, beforeSnap, afterSnap); - showToast('Cut selection to clipboard.', 'info'); - return; - } - } - // No selection: cut entire track (copy + delete) - handleCopyTrack(); - handleDeleteTrack(); - }; - const handleDeleteTrack = () => { - const tid = selectedTrackId; - const sessionTab = sessionTabs.find(s => s.id === activeTab); - if (sessionTab) { - updateActiveTracks(prev => prev.filter(t => t.id !== tid)); - setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1'); - } else { - const curTracks = tracks; - const track = curTracks.find(t => t.id === tid); - if (track && track.sections && track.sections.length > 0) { - showToast('Không thể xoá track chứa Section item.', 'warning'); - return; - } - setTracks(p => p.filter(t => t.id !== tid)); - setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1'); - } - showToast('Deleted track.', 'info'); - }; - - // ── Server Health Check ── - const [viewportWidth, setViewportWidth] = useState(1200); - useEffect(() => { - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const observer = new ResizeObserver(entries => { - for (let entry of entries) { - setViewportWidth(entry.contentRect.width); - } - }); - observer.observe(wrapper); - return () => observer.disconnect(); - }, []); - useEffect(() => { - fetch(API_BASE_URL).then(r => { - if (r.ok) setServerStatus('connected');else setServerStatus('error'); - }).catch(() => setServerStatus('offline')); - }, []); - - // ── Computed Values ── - const maxDuration = useMemo(() => { - let max = 10; - tracks.forEach(t => { - const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default', - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name, - speed: t.speed || 1.0 - }] : []; - clips.forEach(c => { - if (c.buffer) { - const cStart = c.startTime || 0; - const cDur = c.buffer.duration / (c.speed || 1.0); - max = Math.max(max, cStart + cDur); - } - }); - }); - return max; - }, [tracks]); - const maxDurationRef = useRef(maxDuration); - maxDurationRef.current = maxDuration; - const minZoom = useMemo(() => { - return viewportWidth / maxDuration; - }, [viewportWidth, maxDuration]); - const timelineWidth = useMemo(() => { - return Math.max(zoom * maxDuration, viewportWidth); - }, [zoom, maxDuration, viewportWidth]); - useEffect(() => { - if (zoom < minZoom) { - setZoom(minZoom); - } - }, [minZoom]); - const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]); - const selLeft = useMemo(() => { - if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { - return Math.min(localSelectionStart, localSelectionEnd); - } - if (selectionStart === null || selectionEnd === null) return null; - return Math.min(selectionStart, selectionEnd); - }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); - const selRight = useMemo(() => { - if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { - return Math.max(localSelectionStart, localSelectionEnd); - } - if (selectionStart === null || selectionEnd === null) return null; - return Math.max(selectionStart, selectionEnd); - }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); - const dspSelectionStats = useMemo(() => { - const track = tracks.find(t => t.id === selectedTrackId); - if (!track) return null; - const numChannels = track.buffer ? track.buffer.numberOfChannels || 1 : 0; - if (selLeft === null || selRight === null || selRight <= selLeft || !track.buffer) { - return { - trackName: track.name, - channels: numChannels, - timeRange: 'Chưa chọn vùng', - peakVolume: 'N/A' - }; - } - const buffer = track.buffer; - const sampleRate = buffer.sampleRate; - const startSample = Math.max(0, Math.min(buffer.length - 1, Math.floor(selLeft * sampleRate))); - const endSample = Math.max(0, Math.min(buffer.length, Math.floor(selRight * sampleRate))); - let maxVal = 0; - for (let c = 0; c < numChannels; c++) { - const data = buffer.getChannelData(c); - for (let i = startSample; i < endSample; i++) { - const val = Math.abs(data[i]); - if (val > maxVal) maxVal = val; - } - } - let peakDb = 'N/A'; - if (maxVal > 0) { - const db = 20 * Math.log10(maxVal); - peakDb = db.toFixed(2) + ' dB'; - } else { - peakDb = '-∞ dB'; - } - return { - trackName: track.name, - channels: numChannels, - timeRange: `${selLeft.toFixed(2)}s - ${selRight.toFixed(2)}s (${(selRight - selLeft).toFixed(2)}s)`, - peakVolume: peakDb - }; - }, [tracks, selectedTrackId, selLeft, selRight]); - - // ── Toast helper ── - const showToast = (text, type = 'info', actionText = null, onActionClick = null) => { - if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); - setToastMessage({ - text, - type, - actionText, - onActionClick - }); - toastTimeoutRef.current = setTimeout(() => setToastMessage(null), actionText ? 8000 : 3500); - }; - - // ── Server-side upload ── - const uploadToServer = async (file, trackId) => { - const formData = new FormData(); - formData.append('file', file); - try { - const resp = await fetch(`${API_AUDIO}/upload`, { - method: 'POST', - body: formData - }); - if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`); - const data = await resp.json(); - serverFileIdMap[trackId] = data.file_id; - return data; - } catch (err) { - console.warn('Server upload failed, using client-side only:', err.message); - return null; - } - }; - - // ── Server-side waveform loading ── - const loadServerWaveform = async fileId => { - try { - const resp = await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`); - if (!resp.ok) return null; - return await resp.json(); - } catch { - return null; - } - }; - - // ── Check Celery task result ── - const pollTaskResult = async (taskId, maxPoll = 10) => { - for (let i = 0; i < maxPoll; i++) { - await new Promise(r => setTimeout(r, 1500)); - try { - const resp = await fetch(`${API_TASKS}/${taskId}`); - if (!resp.ok) continue; - const data = await resp.json(); - if (data.status === 'SUCCESS') return data.result; - if (data.status === 'FAILURE') throw new Error(data.error || 'Task failed'); - } catch (err) { - throw err; - } - } - throw new Error('Task polling timeout'); - }; - - // ── Wheel Zoom ── - useEffect(() => { - const timeline = timelineWrapperRef.current; - if (!timeline) return; - const handleWheel = e => { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - setZoom(prevZoom => { - let newZoom = prevZoom * zoomFactor; - if (newZoom < minZoom) newZoom = minZoom; - if (newZoom > 50000) newZoom = 50000; - requestAnimationFrame(() => { - const centerTime = currentTimeRef.current; - const newCenterX = centerTime * newZoom; - const viewportWidth = timeline.clientWidth; - timeline.scrollLeft = newCenterX - viewportWidth / 2; - }); - return newZoom; - }); - } else if (e.shiftKey) { - e.preventDefault(); - timeline.scrollLeft += e.deltaY; - } - }; - timeline.addEventListener('wheel', handleWheel, { - passive: false - }); - return () => timeline.removeEventListener('wheel', handleWheel); - }, [minZoom]); - - // ── Update Playhead ── - const startSubTabPlayback = (st, offsetWallTime) => { - const context = getAudioContext(); - const speed = st.speed || 1.0; - const offsetBuffer = offsetWallTime * speed; - const eff = st.buffer.getChannelData(0); - const edBuffer = context.createBuffer(1, eff.length, st.buffer.sampleRate); - const edData = edBuffer.getChannelData(0); - edData.set(eff); - const fx = st.effects || {}; - if (fx.reverse) { - const reversed = new Float32Array(edData); - for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i]; - edBuffer.copyToChannel(reversed, 0); - } - if (fx.gainDb !== 0) { - const gain = Math.pow(10, fx.gainDb / 20); - for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain)); - } - if (fx.fadeInMs > 0) { - const sr = edBuffer.sampleRate; - const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr)); - for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples); - } - if (fx.fadeOutMs > 0) { - const sr = edBuffer.sampleRate; - const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); - for (let i = edData.length - fadeSamples; i < edData.length; i++) { - edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples); - } - } - const source = context.createBufferSource(); - source.buffer = edBuffer; - source.playbackRate.value = speed; - activePlaybackSpeedRef.current = speed; - - // Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3) - // source → volumeGainNode → pannerNode → fadeGainNode → destination - - const volumeGainNode = context.createGain(); - volumeGainNode.gain.setValueAtTime(1.0, context.currentTime); - const pannerNode = context.createStereoPanner(); - pannerNode.pan.setValueAtTime(0.0, context.currentTime); - const fadeGainNode = context.createGain(); - fadeGainNode.gain.setValueAtTime(1.0, context.currentTime); - - // Schedule volume automation nodes - const volNodes = st.volumeNodes || []; - if (volNodes.length > 0) { - volumeGainNode.gain.cancelScheduledValues(context.currentTime); - volNodes.forEach((n, i) => { - const t = context.currentTime + n.time / speed; - const linearGain = Math.pow(10, n.db / 20); - if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t);else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t); - }); - } - - // Schedule panning automation nodes - const panNodes = st.panningNodes || []; - if (panNodes.length > 0) { - pannerNode.pan.cancelScheduledValues(context.currentTime); - panNodes.forEach((n, i) => { - const t = context.currentTime + n.time / speed; - const clamped = Math.max(-1, Math.min(1, n.pan)); - if (i === 0) pannerNode.pan.setValueAtTime(clamped, t);else pannerNode.pan.linearRampToValueAtTime(clamped, t); - }); - } - - // Schedule fade curves - const duration = st.buffer.duration; - const fIn = st.fadeInLen || 0; - const fOut = st.fadeOutLen || 0; - if (fIn > 0) { - fadeGainNode.gain.setValueAtTime(0.0, context.currentTime); - fadeGainNode.gain.linearRampToValueAtTime(1.0, context.currentTime + fIn / speed); - } - if (fOut > 0) { - const fadeOutStart = (duration - fOut) / speed; - fadeGainNode.gain.setValueAtTime(1.0, context.currentTime + Math.max(0, fadeOutStart)); - fadeGainNode.gain.linearRampToValueAtTime(0.0, context.currentTime + duration / speed); - } - source.connect(volumeGainNode); - volumeGainNode.connect(pannerNode); - pannerNode.connect(fadeGainNode); - fadeGainNode.connect(context.destination); - source.start(context.currentTime, offsetBuffer); - activeSourcesRef.current = [source]; - activeTrackNodesRef.current[st.trackId] = { - gainNode: volumeGainNode, - pannerNode, - source - }; - startOffsetTimeRef.current = offsetWallTime; - startBufferOffsetRef.current = offsetBuffer; - startAudioTimeRef.current = context.currentTime; - }; - const updatePlayhead = () => { - if (activeTabRef.current !== 'main') { - const st = subTabsRef.current.find(s => s.id === activeTabRef.current); - if (!st || !st.isPlaying || !st.buffer) return; - const context = getAudioContext(); - const speedFactor = st.speed || 1.0; - const elapsed = context.currentTime - startAudioTimeRef.current; - const wallTime = startOffsetTimeRef.current + elapsed; - const bufferPos = startBufferOffsetRef.current + elapsed * speedFactor; - - // Loop sub-tab selection (bufferPos is buffer-time) - if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) { - const start = Math.min(st.selectionStart, st.selectionEnd); - const end = Math.max(st.selectionStart, st.selectionEnd); - if (bufferPos >= end) { - stopAllPlayback(); - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { - ...s, - currentTime: wallTime, - isPlaying: true - } : s)); - startSubTabPlayback(st, wallTime); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - return; - } - } - if (bufferPos >= st.buffer.duration) { - stopAllPlayback(); - if (isLoopingSelection) { - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { - ...s, - currentTime: 0, - isPlaying: true - } : s)); - startSubTabPlayback(st, 0); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - } else { - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { - ...s, - currentTime: 0, - isPlaying: false - } : s)); - } - return; - } - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { - ...s, - currentTime: wallTime - } : s)); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - return; - } - if (!isPlaying) return; - const context = getAudioContext(); - const elapsed = context.currentTime - startAudioTimeRef.current; - const updatedTime = startOffsetTimeRef.current + elapsed; - - // Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 - // If selection cleared by user, play linearly (don't loop) - if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) { - if (selRight > selLeft && updatedTime >= selRight) { - if (soloedTrackId !== null || selectionMode === 'local') { - stopAllPlayback(); - startOffsetTimeRef.current = selLeft; - startAudioTimeRef.current = context.currentTime; - const soloTid = soloedTrackId !== null ? soloedTrackId : localSelectionTrackId; - startLocalTrackPlayback(soloTid, selLeft); - setCurrentTime(selLeft); - setIsPlaying(true); - } else { - stopAllPlayback(); - startOffsetTimeRef.current = selLeft; - startAudioTimeRef.current = context.currentTime; - startTrackPlayback(selLeft); - setCurrentTime(selLeft); - setIsPlaying(true); - } - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - return; - } - } - if (updatedTime >= maxDurationRef.current) { - if (isLoopingSelection) { - stopAllPlayback(); - startOffsetTimeRef.current = 0; - startAudioTimeRef.current = context.currentTime; - startTrackPlayback(0); - setCurrentTime(0); - setIsPlaying(true); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - return; - } - stopAllPlayback(); - setCurrentTime(0); - return; - } - setCurrentTime(updatedTime); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - }; - useEffect(() => { - if (isPlaying || subTabs.some(s => s.isPlaying)) { - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - } else { - cancelAnimationFrame(animationFrameIdRef.current); - } - return () => cancelAnimationFrame(animationFrameIdRef.current); - }, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]); - - // ── Playback ── - const startTrackPlayback = offsetTime => { - const context = getAudioContext(); - const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null; - tracks.forEach(track => { - const isPlayable = hasSolo ? track.id === soloedTrackId || track.solo : !track.muted; - if (!isPlayable) return; - - // Create persistent gain & panner per track for real-time control - const gainNode = context.createGain(); - const volDb = track.volumeDb ?? 0; - const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20); - gainNode.gain.setValueAtTime(volLinear, context.currentTime); - const pannerNode = context.createStereoPanner(); - pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime); - pannerNode.connect(context.destination); - activeTrackNodesRef.current[track.id] = { - gainNode, - pannerNode - }; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - clips.forEach(clip => { - if (!clip.buffer) return; - const source = context.createBufferSource(); - source.buffer = clip.buffer; - source.playbackRate.value = clip.speed || 1.0; - source.connect(gainNode); - gainNode.connect(pannerNode); - const clipStart = clip.startTime || 0; - const clipDuration = clip.buffer.duration / (clip.speed || 1.0); - const clipEnd = clipStart + clipDuration; - if (offsetTime < clipStart) { - const delay = clipStart - offsetTime; - source.start(context.currentTime + delay, 0); - activeSourcesRef.current.push(source); - } else if (offsetTime < clipEnd) { - const playOffset = offsetTime - clipStart; - source.start(context.currentTime, playOffset * (clip.speed || 1.0)); - activeSourcesRef.current.push(source); - } - }); - }); - }; - - // Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) - const startLocalTrackPlayback = (trackId, offsetTime) => { - const context = getAudioContext(); - const track = tracks.find(t => t.id === trackId); - if (!track) return; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - - // Create persistent gain & panner for real-time control - const gainNode = context.createGain(); - const volDb = track.volumeDb ?? 0; - const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20); - gainNode.gain.setValueAtTime(volLinear, context.currentTime); - const pannerNode = context.createStereoPanner(); - pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime); - pannerNode.connect(context.destination); - activeTrackNodesRef.current[trackId] = { - gainNode, - pannerNode - }; - clips.forEach(clip => { - if (!clip.buffer) return; - const source = context.createBufferSource(); - source.buffer = clip.buffer; - source.playbackRate.value = clip.speed || 1.0; - source.connect(gainNode); - gainNode.connect(pannerNode); - const clipStart = clip.startTime || 0; - const clipDuration = clip.buffer.duration / (clip.speed || 1.0); - const clipEnd = clipStart + clipDuration; - if (offsetTime < clipStart) { - const delay = clipStart - offsetTime; - source.start(context.currentTime + delay, 0); - activeSourcesRef.current.push(source); - } else if (offsetTime < clipEnd) { - const playOffset = offsetTime - clipStart; - source.start(context.currentTime, playOffset * (clip.speed || 1.0)); - activeSourcesRef.current.push(source); - } - }); - }; - const handlePlayPause = () => { - if (activeTab !== 'main') { - // Sub-tab playback transport - const st = subTabs.find(s => s.id === activeTab); - if (!st || !st.buffer) return; - if (st.isPlaying) { - stopAllPlayback(); - setSubTabs(prev => prev.map(s => s.id === activeTab ? { - ...s, - isPlaying: false - } : s)); - } else { - stopAllPlayback(); - const startOffset = st.currentTime || 0; - startSubTabPlayback(st, startOffset); - setSubTabs(prev => prev.map(s => s.id === activeTab ? { - ...s, - isPlaying: true, - currentTime: startOffset - } : s)); - } - return; - } - const context = getAudioContext(); - if (isPlaying) { - stopAllPlayback(); - } else { - startOffsetTimeRef.current = currentTime; - startAudioTimeRef.current = context.currentTime; - startTrackPlayback(currentTime); - setIsPlaying(true); - } - }; - handlePlayPauseRef.current = handlePlayPause; - const handlePause = () => { - if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback(); - }; - const stopAllPlayback = () => { - activeSourcesRef.current.forEach(src => { - try { - src.stop(); - } catch (e) {} - }); - activeSourcesRef.current = []; - activeTrackNodesRef.current = {}; - setIsPlaying(false); - setSubTabs(prev => prev.map(s => ({ - ...s, - isPlaying: false - }))); - }; - const handleStop = () => { - stopAllPlayback(); - if (activeTab !== 'main') { - setSubTabs(prev => prev.map(s => s.id === activeTab ? { - ...s, - currentTime: 0 - } : s)); - } else { - setCurrentTime(0); - } - }; - const handleSubTabResizeMouseDown = e => { - e.preventDefault(); - e.stopPropagation(); - const startY = e.clientY; - const startHeight = subTabHeight; - const handleMouseMove = moveEvent => { - const deltaY = moveEvent.clientY - startY; - const newHeight = Math.max(48, Math.min(400, startHeight + deltaY)); - setSubTabHeight(newHeight); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - // ── Playhead set with seek+play ── - const handlePlayheadSet = (time, shiftKey) => { - localSelectionAnchorRef.current = time; - if (isPlaying) { - // Click during playback: seek to position and continue playing - setCurrentTime(time); - stopAllPlayback(); - setTimeout(() => { - startOffsetTimeRef.current = time; - startAudioTimeRef.current = getAudioContext().currentTime; - startTrackPlayback(time); - setIsPlaying(true); - }, 50); - } else { - // Normal click: just set playhead - setCurrentTime(time); - } - }; - const clearLocalSelection = () => { - setSelectionMode(null); - setLocalSelectionTrackId(null); - setLocalSelectionStart(null); - setLocalSelectionEnd(null); - }; - const handleRulerMouseDown = e => { - if (e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - clearLocalSelection(); - setSelectionMode(null); - setSelectionStart(null); - setSelectionEnd(null); - return; - } - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const rawTime = mouseX / zoom; - const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime; - clearLocalSelection(); - setSelectionMode('global'); - rulerDragStartRef.current = time; - isDraggingRulerRef.current = true; - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - // Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection - const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - setSelectionStart(selS); - setSelectionEnd(selE); - } else { - rulerAnchorRef.current = time; - setSelectionStart(time); - setSelectionEnd(time); - } - handlePlayheadSet(time); - }; - - // Global Ruler mousemove is tracked via document listener set up in useEffect - useEffect(() => { - const handleMouseMove = e => { - if (!isDraggingRulerRef.current) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom)); - const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime; - const anchor = rulerAnchorRef.current ?? rulerDragStartRef.current ?? time; - setSelectionStart(Math.min(anchor, time)); - setSelectionEnd(Math.max(anchor, time)); - }; - const handleMouseUp = () => { - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - rulerDragStartRef.current = null; - } - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom, maxDuration]); - - // ── Track Lane Local Selection Drag ── - const localDragInProgressRef = useRef(false); - const localDragTrackRef = useRef(null); - const localDragStartTimeRef = useRef(0); - const localSelectionAnchorRef = useRef(null); - const handleTrackLaneMouseDown = (trackId, time) => { - setSelectedTrackId(trackId); - clearLocalSelection(); - localSelectionAnchorRef.current = time; - setSelectionMode('local'); - setLocalSelectionTrackId(trackId); - setLocalSelectionStart(time); - setLocalSelectionEnd(time); - setSelectionStart(time); - setSelectionEnd(time); - localDragInProgressRef.current = true; - localDragTrackRef.current = trackId; - localDragStartTimeRef.current = time; - }; - - // Document-level mousemove/mouseup for local selection drag - useEffect(() => { - const handleMouseMove = e => { - if (!localDragInProgressRef.current) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); - const anchor = localSelectionAnchorRef.current ?? localDragStartTimeRef.current; - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - setLocalSelectionStart(selS); - setLocalSelectionEnd(selE); - setSelectionStart(selS); - setSelectionEnd(selE); - }; - const handleMouseUp = () => { - if (localDragInProgressRef.current) { - localDragInProgressRef.current = false; - localDragTrackRef.current = null; - localDragStartTimeRef.current = 0; - } - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom, maxDuration]); - const draggedClipRef = useRef(null); - draggedClipRef.current = draggedClip; - const hoveredTrackIdRef = useRef(null); - hoveredTrackIdRef.current = hoveredTrackId; - const captureTrackSnapshotRef = useRef(null); - captureTrackSnapshotRef.current = captureTrackSnapshot; - const draggedSectionItemRef = useRef(null); - draggedSectionItemRef.current = draggedSectionItem; - const resizedSectionItemRef = useRef(null); - resizedSectionItemRef.current = resizedSectionItem; - let clipSeqCounter = 0; - const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; - const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - const existingClips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []; - const clip = existingClips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + track.id); - if (!clip) return; - const beforeSnap = captureTrackSnapshot(trackId); - if (isDuplicate) { - const cloneId = nextClipId(); - const clone = { - ...clip, - id: cloneId, - startTime: clip.startTime || 0, - name: clip.name + ' (Copy)' - }; - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - const newClips = [...existingClips, clone]; - return { - ...t, - clips: newClips, - buffer: newClips[0].buffer, - startTime: newClips[0].startTime, - name: newClips[0].name - }; - } - return t; - })); - setDraggedClip({ - trackId, - clipId: cloneId, - clickOffset, - buffer: clip.buffer, - name: clip.name + ' (Copy)', - beforeSnap, - isDuplicate: false - }); - return; - } - if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - return { - ...t, - clips: existingClips - }; - } - return t; - })); - } - setDraggedClip({ - trackId, - clipId: clip.id, - clickOffset, - buffer: clip.buffer, - name: clip.name, - beforeSnap, - isDuplicate: false - }); - }; - const stretchedClipRef = useRef(null); - stretchedClipRef.current = stretchedClip; - const handleClipStretchStart = (trackId, clipId, clickTime) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []; - const clip = clips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + trackId); - if (!clip || !clip.buffer) return; - const beforeSnap = captureTrackSnapshot(trackId); - if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - clips - } : t)); - } - setStretchedClip({ - trackId, - clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, - originalDuration: clip.buffer.duration, - startTime: clip.startTime, - originalSpeed: clip.speed || 1.0, - beforeSnap - }); - }; - const handleSelectionEdgeDragStart = (e, trackId, side) => { - const startX = e.clientX; - const initialLeft = Math.min(localSelectionStart, localSelectionEnd); - const initialRight = Math.max(localSelectionStart, localSelectionEnd); - const handleMouseMove = moveEvent => { - const deltaX = moveEvent.clientX - startX; - const deltaSec = deltaX / zoom; - if (side === 'left') { - const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); - setLocalSelectionStart(newLeft); - setLocalSelectionEnd(initialRight); - } else { - const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); - setLocalSelectionStart(initialLeft); - setLocalSelectionEnd(newRight); - } - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - const handleTrackResizeMouseDown = (e, trackId) => { - e.preventDefault(); - e.stopPropagation(); - const startY = e.clientY; - const track = tracks.find(t => t.id === trackId); - const startHeight = track ? track.height || 96 : 96; - const handleMouseMove = moveEvent => { - const deltaY = moveEvent.clientY - startY; - const newHeight = Math.max(110, Math.min(300, startHeight + deltaY)); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - height: newHeight - } : t)); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - const deleteTrack = trackId => { - const sessionTab = sessionTabs.find(s => s.id === activeTab); - if (sessionTab) { - updateActiveTracks(prev => { - const filtered = prev.filter(t => t.id !== trackId); - if (filtered.length > 0) setSelectedTrackId(filtered[0].id); - return filtered; - }); - } else { - const track = tracks.find(t => t.id === trackId); - if (track && track.sections && track.sections.length > 0) { - showToast('Không thể xoá track chứa Section item.', 'warning'); - return; - } - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => { - const filtered = prev.filter(t => t.id !== trackId); - if (filtered.length > 0) setSelectedTrackId(filtered[0].id); - return filtered; - }); - } - showToast('Đã xóa track.', 'info'); - }; - useEffect(() => { - const handleMouseMove = e => { - const drag = draggedClipRef.current; - if (!drag) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = mouseX / zoom; - const rawStart = Math.max(0, time - drag.clickOffset); - const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); - const targetTrackId = hoveredTrackIdRef.current || drag.trackId; - setTracks(prev => prev.map(t => { - // Clear the clip from its previous track if it moved to a new track - if (t.id === drag.trackId && drag.trackId !== targetTrackId) { - const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId); - return { - ...t, - clips: updatedClips, - buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, - startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, - name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` - }; - } - // Update/set clip on target track - if (t.id === targetTrackId) { - const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []; - const hasClip = existingClips.some(c => c.id === drag.clipId); - let updatedClips; - if (hasClip) { - updatedClips = existingClips.map(c => c.id === drag.clipId ? { - ...c, - startTime: newStart - } : c); - } else { - updatedClips = [...existingClips, { - id: drag.clipId, - buffer: drag.buffer, - startTime: newStart, - name: drag.name - }]; - } - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0].buffer, - startTime: updatedClips[0].startTime, - name: updatedClips[0].name - }; - } - return t; - })); - if (drag.trackId !== targetTrackId) { - setDraggedClip(prev => ({ - ...prev, - trackId: targetTrackId - })); - } - }; - const handleMouseUp = () => { - const drag = draggedClipRef.current; - if (!drag) return; - const afterSnap = captureTrackSnapshotRef.current(drag.trackId); - pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); - setDraggedClip(null); - showToast('Đã di chuyển clip.', 'success'); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom]); - - // Document-level mousemove/mouseup for clip stretching - useEffect(() => { - const handleMouseMove = e => { - const stretch = stretchedClipRef.current; - if (!stretch) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = mouseX / zoom; - const newDuration = Math.max(0.1, time - stretch.startTime); - const speedRatio = stretch.originalDuration / newDuration; - setTracks(prev => prev.map(t => { - if (t.id === stretch.trackId) { - const updatedClips = (t.clips || []).map(c => { - if (c.id === stretch.clipId) { - return { - ...c, - speed: speedRatio - }; - } - return c; - }); - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer, - startTime: updatedClips[0]?.startTime || 0, - speed: updatedClips[0]?.speed || 1.0 - }; - } - return t; - })); - }; - const handleMouseUp = () => { - const stretch = stretchedClipRef.current; - if (!stretch) return; - const afterSnap = captureTrackSnapshotRef.current(stretch.trackId); - pushAction('STRETCH_CLIP', stretch.trackId, stretch.beforeSnap, afterSnap); - setStretchedClip(null); - showToast('Đã giãn thời gian clip.', 'success'); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom]); - - // ── Section / MIDI Item Drag Start ── - const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => { - setDraggedSectionItem({ trackId, itemType, itemId, clickOffset }); - }; - - // ── Section / MIDI Item Resize Start ── - const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => { - const curTracks = activeTracks; - const track = curTracks.find(t => t.id === trackId); - if (!track) return; - const items = itemType === 'section' ? track.sections : track.midiItems; - const item = (items || []).find(it => it.id === itemId); - if (!item) return; - const start = itemType === 'section' ? item.start : item.startTime; - setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration }); - }; - - // ── Document-level mousemove/mouseup for Section/MIDI item drag ── - useEffect(() => { - const handleMouseMove = e => { - const drag = draggedSectionItemRef.current; - if (!drag) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = mouseX / zoom; - const newStart = Math.max(0, time - drag.clickOffset); - const targetTrackId = hoveredTrackIdRef.current || drag.trackId; - updateActiveTracks(prev => prev.map(t => { - const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []); - const updatedItems = items.filter(it => it.id !== drag.itemId); - if (t.id === targetTrackId) { - const movedItem = items.find(it => it.id === drag.itemId); - if (movedItem) { - updatedItems.push(drag.itemType === 'section' - ? { ...movedItem, start: newStart } - : { ...movedItem, startTime: newStart }); - } else { - if (drag.itemType === 'section') { - updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' }); - } else { - updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' }); - } - } - } - return drag.itemType === 'section' - ? { ...t, sections: updatedItems } - : { ...t, midiItems: updatedItems }; - })); - if (drag.trackId !== targetTrackId) { - setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId })); - } - }; - const handleMouseUp = () => { - const drag = draggedSectionItemRef.current; - if (!drag) return; - setDraggedSectionItem(null); - showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success'); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom, activeTab, sessionTabs]); - - // ── Document-level mousemove/mouseup for Section/MIDI item resize ── - useEffect(() => { - const handleMouseMove = e => { - const resize = resizedSectionItemRef.current; - if (!resize) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = mouseX / zoom; - updateActiveTracks(prev => prev.map(t => { - if (t.id !== resize.trackId) return t; - const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])]; - const idx = items.findIndex(it => it.id === resize.itemId); - if (idx === -1) return t; - const item = items[idx]; - if (resize.side === 'left') { - const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1); - const end = resize.originalStart + resize.originalDuration; - const newDuration = end - newStart; - if (newDuration < 0.1) return t; - items[idx] = resize.itemType === 'section' - ? { ...item, start: newStart, duration: newDuration } - : { ...item, startTime: newStart, duration: newDuration }; - } else { - const newDuration = Math.max(0.1, time - resize.originalStart); - items[idx] = { ...item, duration: newDuration }; - } - return resize.itemType === 'section' - ? { ...t, sections: items } - : { ...t, midiItems: items }; - })); - }; - const handleMouseUp = () => { - const resize = resizedSectionItemRef.current; - if (!resize) return; - setResizedSectionItem(null); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom, activeTab, sessionTabs]); - - const handleSelectRange = (start, end, reset) => { - const maxLen = maxDuration; - const cleanStart = Math.max(0, Math.min(maxLen, start)); - const cleanEnd = Math.max(0, Math.min(maxLen, end)); - if (reset) { - setSelectionStart(cleanStart); - setSelectionEnd(cleanEnd); - } else { - setSelectionEnd(cleanEnd); - } - // LOOP_EDITOR_2.md §4.2: new selection = enable looping - setSelectionCleared(false); - }; - const handleSelectionInputChange = (field, val) => { - const numericVal = Math.max(0, parseFloat(val) || 0); - if (selectionMode === 'local') { - // Editing local selection directly - if (field === 'start') { - setLocalSelectionStart(numericVal); - } else { - setLocalSelectionEnd(numericVal); - } - } else { - if (field === 'start') { - setSelectionStart(numericVal); - } else { - setSelectionEnd(numericVal); - } - } - }; - const selectionStats = useMemo(() => { - if (selLeft === null || selRight === null) { - return { - start: 0, - end: 0, - length: 0 - }; - } - const s = Math.min(selLeft, selRight); - const e = Math.max(selLeft, selRight); - return { - start: parseFloat(s.toFixed(3)), - end: parseFloat(e.toFixed(3)), - length: parseFloat((e - s).toFixed(3)) - }; - }, [selLeft, selRight]); - - // ── Handle Drag (selection resize) ── - const handleHandleDragStart = (e, side) => { - e.preventDefault(); - e.stopPropagation(); - const startX = e.clientX; - const useLocal = selectionMode === 'local'; - const currentStart = useLocal ? localSelectionStart : selectionStart; - const currentEnd = useLocal ? localSelectionEnd : selectionEnd; - const initialLeft = Math.min(currentStart, currentEnd); - const initialRight = Math.max(currentStart, currentEnd); - const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; - const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; - const handleMouseMove = moveEvent => { - const deltaX = moveEvent.clientX - startX; - const deltaSec = deltaX / zoom; - if (side === 'left') { - const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); - setStart(newLeft); - setEnd(initialRight); - } else { - const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); - setStart(initialLeft); - setEnd(newRight); - } - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - const handleSelectionBodyDragStart = e => { - e.preventDefault(); - e.stopPropagation(); - const startX = e.clientX; - const useLocal = selectionMode === 'local'; - const currentStart = useLocal ? localSelectionStart : selectionStart; - const currentEnd = useLocal ? localSelectionEnd : selectionEnd; - const initialLeft = Math.min(currentStart, currentEnd); - const initialRight = Math.max(currentStart, currentEnd); - const widthSec = initialRight - initialLeft; - const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; - const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; - const handleMouseMove = moveEvent => { - const deltaX = moveEvent.clientX - startX; - const deltaSec = deltaX / zoom; - let newLeft = initialLeft + deltaSec; - let newRight = initialRight + deltaSec; - if (newLeft < 0) { - newLeft = 0; - newRight = widthSec; - } - if (newRight > maxDuration) { - newRight = maxDuration; - newLeft = maxDuration - widthSec; - } - setStart(newLeft); - setEnd(newRight); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - // ── Track Controls ── - const toggleTrackSoloEvaluate = trackId => { - const wasPlaying = isPlaying; - stopAllPlayback(); - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - return { - ...t, - solo: !t.solo, - muted: false - }; - } - return { - ...t, - solo: false - }; - })); - setSoloedTrackId(prev => prev === trackId ? null : trackId); - if (wasPlaying) { - setTimeout(() => { - startOffsetTimeRef.current = currentTime; - startAudioTimeRef.current = getAudioContext().currentTime; - setIsPlaying(true); - }, 50); - } - }; - const toggleTrackMute = trackId => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - muted: !t.muted - } : t)); - setUndoStack(prev => { - const next = [...prev, { - action_type: 'MUTE', - track_id: trackId, - timestamp: Date.now(), - before_state: beforeSnap, - after_state: captureTrackSnapshot(trackId) - }]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - }; - const updateTrackVolumeDb = (trackId, val) => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - volumeDb: val - } : t)); - setUndoStack(prev => { - const next = [...prev, { - action_type: 'VOLUME_CHANGE', - track_id: trackId, - timestamp: Date.now(), - before_state: beforeSnap, - after_state: captureTrackSnapshot(trackId) - }]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - // Real-time update during playback - const nodes = activeTrackNodesRef.current[trackId]; - if (nodes) { - const volLinear = val <= -50 ? 0 : Math.pow(10, val / 20); - nodes.gainNode.gain.setValueAtTime(volLinear, getAudioContext().currentTime); - } - }; - const updateTrackPan = (trackId, val) => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - pan: val - } : t)); - setUndoStack(prev => { - const next = [...prev, { - action_type: 'PAN_CHANGE', - track_id: trackId, - timestamp: Date.now(), - before_state: beforeSnap, - after_state: captureTrackSnapshot(trackId) - }]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - // Real-time update during playback - const nodes = activeTrackNodesRef.current[trackId]; - if (nodes) { - nodes.pannerNode.pan.setValueAtTime(val / 100, getAudioContext().currentTime); - } - }; - const updateTrackName = (trackId, name) => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - name - } : t)); - setUndoStack(prev => { - const next = [...prev, { - action_type: 'RENAME', - track_id: trackId, - timestamp: Date.now(), - before_state: beforeSnap, - after_state: captureTrackSnapshot(trackId) - }]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - }; - const updateTrackColor = (trackId, color) => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - color - } : t)); - setUndoStack(prev => { - const next = [...prev, { - action_type: 'RECOLOR', - track_id: trackId, - timestamp: Date.now(), - before_state: beforeSnap, - after_state: captureTrackSnapshot(trackId) - }]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - }; - const updateClipName = (trackId, clipId, newName) => { - setTracks(prev => prev.map(t => { - if (t.id !== trackId) return t; - const clips = t.clips && t.clips.length > 0 ? t.clips : []; - return { - ...t, - clips: clips.map(c => c.id === clipId ? { - ...c, - name: newName - } : c) - }; - })); - }; - - // ── Load File on Track (with server upload) ── - const loadFileOnTrack = async (trackId, file) => { - if (!file) return; - showToast(`Đang nạp file ${file.name}...`, 'info'); - try { - // Upload to server - uploadToServer(file, trackId); - - // Decode locally for playback + analyze channels (stereo/mono) - const { - audioBuffer: decodedBuffer, - channelInfo - } = await window.SonicAudio.decodeAudioFile(file); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - name: file.name, - buffer: decodedBuffer, - channelInfo: channelInfo - } : t)); - showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`, 'success'); - } catch (err) { - showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error'); - } - }; - - // ── Synth Generators ── - const generateSynthToTrack = (trackId, type) => { - const sampleRate = 44100; - const duration = 12.0; - const context = getAudioContext(); - const frameCount = sampleRate * duration; - const newBuffer = context.createBuffer(1, frameCount, sampleRate); - const channelData = newBuffer.getChannelData(0); - if (type === 'kick') { - for (let i = 0; i < frameCount; i++) { - const t = i / sampleRate; - const beatTime = t % 0.5; - const freq = 120 * Math.exp(-35 * beatTime); - channelData[i] = Math.sin(2 * Math.PI * freq * beatTime) * Math.exp(-6 * beatTime); - } - } else { - const notes = [220.00, 261.63, 293.66, 329.63, 392.00]; - for (let i = 0; i < frameCount; i++) { - const t = i / sampleRate; - const noteIdx = Math.floor(t * 2) % notes.length; - const freq = notes[noteIdx]; - channelData[i] = Math.sin(2 * Math.PI * freq * t) * 0.25 * (1.0 - t % 0.5 / 0.5); - } - } - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - name: `Demo_${type.toUpperCase()}.wav`, - buffer: newBuffer - } : t)); - showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success'); - }; - - // ── Update tracks in active context (main session or section tab) ── - const updateActiveTracks = updater => { - const sessionTab = sessionTabs.find(s => s.id === activeTab); - if (sessionTab) { - setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st)); - } else { - setTracks(updater); - } - }; - - // ── Add Track ── - const addNewTrack = () => { - const curTracks = activeTracks; - const newId = (curTracks.length + 1).toString(); - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const selectColor = colors[curTracks.length % colors.length]; - updateActiveTracks(prev => [...prev, { - id: newId, - name: `Track ${newId}`, - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: selectColor, - markers: [], - serverFileId: null, - clips: [], - sections: [], - midiItems: [] - }]); - showToast(`Đã thêm Track ${newId}.`, 'info'); - setTimeout(() => lucide.createIcons(), 200); - return newId; - }; - - // ── Insert Track Below Selected ── - const insertTrackBelow = () => { - const curTracks = activeTracks; - const newId = `t${Date.now()}`; - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const selectColor = colors[curTracks.length % colors.length]; - const newTrack = { - id: newId, - name: `Track ${newId}`, - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: selectColor, - markers: [], - serverFileId: null, - clips: [], - sections: [], - midiItems: [] - }; - updateActiveTracks(prev => { - const idx = prev.findIndex(t => t.id === selectedTrackId); - if (idx === -1) return [...prev, newTrack]; - const copy = [...prev]; - copy.splice(idx + 1, 0, newTrack); - return copy; - }); - setSelectedTrackId(newId); - showToast(`Đã thêm Track ${newId}.`, 'info'); - setTimeout(() => lucide.createIcons(), 200); - }; - - // ── Insert Section at Playhead ── - const insertSectionAtPlayhead = () => { - const track = tracks.find(t => t.id === selectedTrackId); - if (!track) { showToast('Chọn track trước', 'warning'); return; } - const section = { - id: `sec_${Date.now()}`, - name: 'Section', - start: currentTime, - duration: 4, - color: track.color || '#06b6d4' - }; - setTracks(prev => prev.map(t => t.id === selectedTrackId ? { - ...t, - sections: [...(t.sections || []), section] - } : t)); - showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`, 'success'); - }; - - // ── Insert MIDI Item at Playhead ── - const insertMidiItemAtPlayhead = () => { - const curTracks = activeTracks; - const track = curTracks.find(t => t.id === selectedTrackId); - if (!track) { showToast('Chọn track trước', 'warning'); return; } - const midiItem = { - id: `midi_${Date.now()}`, - name: 'MIDI Item', - startTime: currentTime, - duration: 4, - notes: [], - color: '#a78bfa' - }; - updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? { - ...t, - midiItems: [...(t.midiItems || []), midiItem] - } : t)); - showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`, 'success'); - }; - - // ── Insert Sound Clip at Cursor ── - const insertSoundClipAtCursor = () => { - const curTracks = activeTracks; - const track = curTracks.find(t => t.id === selectedTrackId); - if (!track) { showToast('Chọn track trước', 'warning'); return; } - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'audio/*'; - input.multiple = true; - input.onchange = async e => { - const files = Array.from(e.target.files || []); - if (!files.length) return; - let offset = currentTime; - let count = 0; - for (const file of files) { - try { - uploadToServer(file, track.id); - const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file); - const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; - const newClip = { id: clipId, buffer: decodedBuffer, startTime: offset, name: file.name, speed: 1.0 }; - updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? { - ...t, - clips: [...(t.clips || []), newClip] - } : t)); - offset += decodedBuffer.duration; - count++; - } catch (err) { - showToast(`Lỗi nạp ${file.name}`, 'error'); - } - } - if (count > 0) showToast(`Đã chèn ${count} file âm thanh`, 'success'); - }; - input.click(); - }; - - // ── Server-side Export ── - const triggerWavExport = async () => { - let exportTracks; - let clipStart = 0; - let clipEnd = 0; - const src = exportSettings.source; - if (src === 'active_clip' || src === 'clip_selection') { - const selTrack = selectedTrackId ? tracks.find(t => t.id === selectedTrackId) : null; - if (!selTrack || !selTrack.buffer) { - showToast("Không có clip nào được chọn.", "warning"); - return; - } - let rangeStart = selectionStart; - let rangeEnd = selectionEnd; - if (rangeStart === null || rangeEnd === null || rangeEnd <= rangeStart) { - rangeStart = 0; - rangeEnd = selTrack.buffer.duration; - } - const ctx = getAudioContext(); - const numCh = selTrack.buffer.numberOfChannels || 1; - const sr = selTrack.buffer.sampleRate; - const startSample = Math.max(0, Math.floor(rangeStart * sr)); - const endSample = Math.min(selTrack.buffer.length, Math.floor(rangeEnd * sr)); - const len = endSample - startSample; - if (len <= 100) { - showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.", "warning"); - return; - } - const clipBuf = ctx.createBuffer(numCh, len, sr); - for (let ch = 0; ch < numCh; ch++) { - clipBuf.copyToChannel(selTrack.buffer.getChannelData(ch).subarray(startSample, endSample), ch); - } - exportTracks = [{ - ...selTrack, - buffer: clipBuf, - startTime: 0, - clips: [{ - id: 'export_clip', - buffer: clipBuf, - startTime: 0, - name: selTrack.name - }] - }]; - } else if (src === 'track_mix') { - const sel = tracks.filter(t => t.buffer && !t.muted); - const selTrk = selectedTrackId ? sel.filter(t => t.id === selectedTrackId) : sel; - if (selTrk.length === 0) { - showToast("Track được chọn không có dữ liệu.", "warning"); - return; - } - exportTracks = selTrk; - } else { - exportTracks = tracks.filter(t => t.buffer && !t.muted); - } - if (exportTracks.length === 0) { - showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning"); - return; - } - - // Check if all active tracks have server file IDs - const allOnServer = exportTracks.every(t => serverFileIdMap[t.id]); - if (allOnServer && serverStatus === 'connected') { - // Use server-side export - setIsExporting(true); - showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info"); - try { - const sessionId = `session_${Date.now()}`; - const tracksMeta = exportTracks.map(t => ({ - track_id: t.id, - file_id: serverFileIdMap[t.id], - volume_db: t.volumeDb, - muted: false, - clips: [{ - clip_id: `clip_${t.id}`, - start_time_seconds: t.startTime || 0, - end_time_seconds: (t.startTime || 0) + t.buffer.duration, - loop_count: 1, - apply_zero_crossing: true, - fade_in_ms: 0, - fade_out_ms: 0 - }] - })); - const resp = await fetch(`${API_MULTITRACK}/mix`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - session_id: sessionId, - export_settings: { - sample_rate: parseInt(exportSettings.sampleRate), - bit_depth: parseInt(exportSettings.bitDepth), - format: exportSettings.format, - channels: exportSettings.channels - }, - tracks: tracksMeta - }) - }); - if (!resp.ok) throw new Error(`Server export failed: ${resp.status}`); - const data = await resp.json(); - showToast("Đang xử lý trên máy chủ...", "info"); - - // Poll for result - const result = await pollTaskResult(data.task_id, 20); - if (result.success) { - // Get the uploaded file from server - const fileId = result.output_file_id || result.output_path?.split('/').pop(); - if (fileId) { - const downloadUrl = `${API_AUDIO}/download/${fileId}`; - showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success", "Tải về", () => { - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = fileId; - a.click(); - }); - } - } else { - throw new Error(result.error || 'Server processing failed'); - } - } catch (err) { - showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning"); - // Fall back to client-side export - clientSideExport(exportTracks); - } finally { - setIsExporting(false); - } - } else { - // Client-side export (existing working code) - clientSideExport(exportTracks); - } - }; - const handleSaveProject = async () => { - if (!currentUser) { - setIsMandatoryLogin(false); - setAuthMode('login'); - setAuthModalOpen(true); - return; - } - if (!projectName) { - setSaveProjectModalOpen(true); - return; - } - const ss = arr => (arr || []).map(t => ({ - id: t.id, - name: t.name, - startTime: t.startTime, - height: t.height, - volumeDb: t.volumeDb, - pan: t.pan, - muted: t.muted, - solo: t.solo, - color: t.color, - markers: t.markers || [], - serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { - channels: t.channelInfo.channels, - isStereo: t.channelInfo.isStereo, - label: t.channelInfo.label - } : null - })); - const dataJson = JSON.stringify({ - id: currentProjectId || 'project_' + Date.now(), - name: projectName, - tracks: ss(tracks) - }); - try { - if (currentProjectId) { - await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson); - showToast(`Đã cập nhật dự án "${projectName}" lên server!`, "success"); - } else { - const res = await window.SonicAPI.saveCloudProject(projectName, dataJson); - const newProjId = res.project_id; - if (newProjId) { - setCurrentProjectId(newProjId); - localStorage.setItem('sonic_project_id', newProjId); - } - showToast(`Đã lưu dự án "${projectName}" mới lên server!`, "success"); - } - } catch (err) { - showToast(err.message || "Lỗi lưu server", "error"); - } - }; - const handleSaveProjectWithName = async newName => { - setProjectName(newName); - localStorage.setItem('sonic_project_name', newName); - const ss = arr => (arr || []).map(t => ({ - id: t.id, - name: t.name, - startTime: t.startTime, - height: t.height, - volumeDb: t.volumeDb, - pan: t.pan, - muted: t.muted, - solo: t.solo, - color: t.color, - markers: t.markers || [], - serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { - channels: t.channelInfo.channels, - isStereo: t.channelInfo.isStereo, - label: t.channelInfo.label - } : null - })); - const dataJson = JSON.stringify({ - id: 'project_' + Date.now(), - name: newName, - tracks: ss(tracks) - }); - try { - const res = await window.SonicAPI.saveCloudProject(newName, dataJson); - const newProjId = res.project_id; - if (newProjId) { - setCurrentProjectId(newProjId); - localStorage.setItem('sonic_project_id', newProjId); - } - showToast(`Đã lưu dự án "${newName}" mới lên server!`, "success"); - } catch (err) { - showToast(err.message || "Lỗi lưu server", "error"); - } - }; - const handleSaveAsCloud = async newName => { - const ss = arr => (arr || []).map(t => ({ - id: t.id, - name: t.name, - startTime: t.startTime, - height: t.height, - volumeDb: t.volumeDb, - pan: t.pan, - muted: t.muted, - solo: t.solo, - color: t.color, - markers: t.markers || [], - serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { - channels: t.channelInfo.channels, - isStereo: t.channelInfo.isStereo, - label: t.channelInfo.label - } : null - })); - const dataJson = JSON.stringify({ - id: 'project_' + Date.now(), - name: newName, - tracks: ss(tracks) - }); - try { - const res = await window.SonicAPI.saveCloudProject(newName, dataJson); - const newProjId = res.project_id; - setProjectName(newName); - localStorage.setItem('sonic_project_name', newName); - if (newProjId) { - setCurrentProjectId(newProjId); - localStorage.setItem('sonic_project_id', newProjId); - } - showToast(`Đã lưu dự án dưới tên mới "${newName}" lên server!`, "success"); - } catch (err) { - showToast(err.message || "Lỗi Save As lên server", "error"); - } - }; - const handleSaveCloud = handleSaveProject; - const handleExportSFS = (customName = null) => { - const finalName = customName || projectName || 'Dự án SonicForge'; - const serializeSafe = arr => (arr || []).map(t => ({ - id: t.id, - name: t.name, - startTime: t.startTime, - height: t.height, - volumeDb: t.volumeDb, - pan: t.pan, - muted: t.muted, - solo: t.solo, - color: t.color, - markers: t.markers || [], - serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { - channels: t.channelInfo.channels, - isStereo: t.channelInfo.isStereo, - label: t.channelInfo.label - } : null - })); - window.SonicStorage.exportProjectToSFS({ - id: currentProjectId || 'proj_' + Date.now(), - name: finalName, - tracks: serializeSafe(tracks) - }); - showToast("Đã xuất dự án (.sfs) thành công!", "success"); - }; - const handleImportSFS = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.sfs,application/json'; - input.onchange = async e => { - if (!e.target.files[0]) return; - try { - const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]); - const restored = (proj.tracks || []).map(t => ({ - ...t, - buffer: null, - channelInfo: t.channelInfo || null, - clips: t.clips || [], - serverFileId: t.serverFileId || null - })); - if (restored.length > 0) { - setTracks(restored); - setProjectName(proj.name || 'Dự án mới'); - setCurrentProjectId(null); - localStorage.setItem('sonic_project_name', proj.name || 'Dự án mới'); - localStorage.removeItem('sonic_project_id'); - showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success"); - } - } catch (err) { - showToast(err.message || "Lỗi mở tệp .sfs", "error"); - } - }; - input.click(); - }; - const clientSideExport = async activeTracks => { - setIsExporting(true); - showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info"); - try { - const targetRate = parseInt(exportSettings.sampleRate); - const bitDepth = parseInt(exportSettings.bitDepth); - const durationLimit = Math.max(...activeTracks.map(t => { - const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default', - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name, - speed: t.speed || 1.0 - }] : []; - if (clips.length === 0) return 0; - return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0))); - })); - const outChannels = exportSettings.channels === 'mono' ? 1 : 2; - const offlineCtx = new OfflineAudioContext(outChannels, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); - activeTracks.forEach(t => { - const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default', - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name, - speed: t.speed || 1.0 - }] : []; - clips.forEach(clip => { - if (!clip.buffer) return; - const source = offlineCtx.createBufferSource(); - source.buffer = clip.buffer; - source.playbackRate.value = clip.speed || 1.0; - const gain = offlineCtx.createGain(); - const volDb = t.volumeDb ?? 0; - const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20); - gain.gain.setValueAtTime(volLinear, 0); - const panner = offlineCtx.createStereoPanner(); - panner.pan.setValueAtTime((t.pan ?? 0) / 100, 0); - source.connect(gain); - gain.connect(panner); - panner.connect(offlineCtx.destination); - const clipStart = clip.startTime || 0; - source.start(clipStart); - }); - }); - const renderedBuffer = await offlineCtx.startRendering(); - const numExportCh = renderedBuffer.numberOfChannels; - const exportLength = renderedBuffer.length; - const bytesPerSample = bitDepth / 8; - const headerSize = 44; - const fileSizeBytes = headerSize + exportLength * bytesPerSample * numExportCh; - const fileBuffer = new ArrayBuffer(fileSizeBytes); - const view = new DataView(fileBuffer); - const writeString = (offset, string) => { - for (let i = 0; i < string.length; i++) { - view.setUint8(offset + i, string.charCodeAt(i)); - } - }; - writeString(0, 'RIFF'); - view.setUint32(4, fileSizeBytes - 8, true); - writeString(8, 'WAVE'); - writeString(12, 'fmt '); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, numExportCh, true); - view.setUint32(24, targetRate, true); - view.setUint32(28, targetRate * bytesPerSample * numExportCh, true); - view.setUint16(32, bytesPerSample, true); - view.setUint16(34, bitDepth, true); - writeString(36, 'data'); - view.setUint32(40, exportLength * bytesPerSample * numExportCh, true); - let offset = 44; - for (let i = 0; i < exportLength; i++) { - for (let ch = 0; ch < numExportCh; ch++) { - const chData = renderedBuffer.getChannelData(ch); - const sample = Math.max(-1, Math.min(1, chData[i])); - if (bitDepth === 8) { - view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); - } else if (bitDepth === 16) { - view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); - } else if (bitDepth === 24) { - const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); - view.setUint8(offset, val24 & 0xFF); - view.setUint8(offset + 1, val24 >> 8 & 0xFF); - view.setUint8(offset + 2, val24 >> 16 & 0xFF); - } - offset += bytesPerSample; - } - } - const blob = new Blob([view], { - type: 'audio/wav' - }); - const url = URL.createObjectURL(blob); - if ((exportSettings.format === 'mp3' || exportSettings.format === 'ogg') && serverStatus === 'connected') { - setIsExporting(true); - showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...", "info"); - try { - const file = new File([blob], `session_export.wav`, { - type: 'audio/wav' - }); - const formData = new FormData(); - formData.append('file', file); - const uploadResp = await fetch(`${API_AUDIO}/upload`, { - method: 'POST', - body: formData - }); - if (!uploadResp.ok) throw new Error("Upload to transcode server failed"); - const uploadData = await uploadResp.json(); - const fileId = uploadData.file_id; - const exportResp = await fetch(`${API_AUDIO}/export`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_id: fileId, - format: exportSettings.format, - sample_rate: targetRate, - bit_depth: bitDepth - }) - }); - if (!exportResp.ok) throw new Error("Transcode request failed"); - const exportData = await exportResp.json(); - const result = await pollTaskResult(exportData.task_id, 20); - if (result.success) { - const outId = result.output_file_id; - const downloadUrl = `${API_AUDIO}/download/${outId}`; - showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`, "success", "Tải về", () => { - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`; - a.click(); - }); - } else { - throw new Error(result.error || 'Server encoding failed'); - } - } catch (transcodeErr) { - showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`, "warning", "Tải WAV thay thế", () => { - const a = document.createElement('a'); - a.href = url; - a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`; - a.click(); - }); - } - } else { - if (exportSettings.format !== 'wav') { - showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.", "warning"); - } - const a = document.createElement('a'); - a.href = url; - a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`; - a.click(); - showToast("Xuất bản âm thanh hoàn tất!", "success"); - } - URL.revokeObjectURL(url); - } catch (err) { - showToast("Lỗi xuất âm thanh: " + err.message, "error"); - } finally { - setIsExporting(false); - } - }; - - // ── AI Analysis (server-side with fallback) ── - const triggerAIAnalysis = async () => { - setAnalysisState({ - status: 'Connecting to AI Engine...', - data: null, - isRunning: true - }); - - // Check if we have a file on the server to analyze - const activeTrack = tracks.find(t => t.id === selectedTrackId); - const serverFileId = serverFileIdMap[selectedTrackId]; - if (serverFileId && serverStatus === 'connected') { - try { - const resp = await fetch(`${API_AUDIO}/analyze-ai`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_id: serverFileId, - api_base_url: aiConfig.baseUrl !== API_BASE_URL ? aiConfig.baseUrl : null, - model: aiConfig.model - }) - }); - if (resp.ok) { - const data = await resp.json(); - showToast("Đang phân tích cấu trúc trên máy chủ...", "info"); - try { - const result = await pollTaskResult(data.task_id, 15); - if (result.bpm) { - if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { - const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); - const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); - if (selectionMode === 'local') { - setLocalSelectionStart(snapStart); - setLocalSelectionEnd(snapEnd); - } else { - setSelectionStart(snapStart); - setSelectionEnd(snapEnd); - } - } - setAnalysisState({ - status: 'Hoàn thành phân tích (Server)', - data: result, - isRunning: false - }); - showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected (Zero-Crossing Aligned)`, 'success'); - return; - } - } catch (e) { - console.warn('Server AI poll failed, using fallback:', e); - } - } - } catch (err) { - console.warn('Server AI analysis failed, using fallback:', err); - } - } - - // Fallback: client-side analysis with simulated BPM - setAnalysisState({ - status: 'Processing structural detection...', - data: null, - isRunning: true - }); - setTimeout(() => { - let bpm = 120; - // Try to detect BPM from buffer - if (activeTrack && activeTrack.buffer) { - const data = activeTrack.buffer.getChannelData(0); - const sr = activeTrack.buffer.sampleRate; - // Simple autocorrelation for BPM estimation - const windowSize = Math.min(sr * 3, data.length); - if (windowSize > sr) { - let maxCorr = 0; - let bestLag = Math.floor(sr * 0.25); // ~240 BPM max - for (let lag = Math.floor(sr * 0.25); lag < Math.floor(sr * 2); lag++) { - let corr = 0; - const n = Math.floor(windowSize / 2); - for (let i = 0; i < n; i++) { - corr += data[i] * data[i + lag]; - } - corr /= n; - if (corr > maxCorr) { - maxCorr = corr; - bestLag = lag; - } - } - if (bestLag > 0) { - bpm = Math.round(60 * sr / bestLag); - bpm = Math.max(60, Math.min(200, bpm)); - } - } - } - if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { - const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); - const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); - if (selectionMode === 'local') { - setLocalSelectionStart(snapStart); - setLocalSelectionEnd(snapEnd); - } else { - setSelectionStart(snapStart); - setSelectionEnd(snapEnd); - } - } - setAnalysisState({ - status: 'Hoàn thành phân tích (Client)', - data: { - bpm, - bars: Math.max(4, Math.round(bpm / 30)), - timeSig: '4/4', - detectedKey: 'Am' - }, - isRunning: false - }); - showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`, 'success'); - }, 1500); - }; - - // ── AI Analysic Loop: scan track, detect beats, place markers for loop selection ── - const handleAIAnalysicLoop = async () => { - const forcedTrackId = subTabAiTrackIdRef.current; - subTabAiTrackIdRef.current = null; - const activeTrackId = forcedTrackId || selectedTrackId; - const activeTrack = tracks.find(t => t.id === activeTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.", "warning"); - return; - } - setAnalysisState({ - status: 'AI Analysic Loop: đang phát hiện nhịp...', - data: null, - isRunning: true - }); - showToast("AI Analysic Loop: đang quét cấu trúc nhịp điệu...", "info"); - try { - const buffer = activeTrack.buffer; - const data = buffer.getChannelData(0); - const sr = buffer.sampleRate; - const windowSize = Math.min(sr * 3, data.length); - - // Client-side BPM detection via autocorrelation - let detectedBPM = 120; - if (windowSize > sr) { - let maxCorr = 0; - for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { - let corr = 0; - const step = 4; - for (let i = 0; i < windowSize && i + lag < data.length; i += step) { - corr += data[i] * data[i + lag]; - } - corr /= windowSize / step; - if (corr > maxCorr) { - maxCorr = corr; - detectedBPM = 60 / (lag / sr); - } - } - } - detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); - const beatDuration = 60 / detectedBPM; - const barDuration = beatDuration * 4; - const totalDuration = buffer.duration; - - // Place markers at each bar (strong beat) position - const barMarkers = []; - for (let t = 0; t < totalDuration; t += barDuration) { - const zcTime = findZeroCrossing(buffer, t); - barMarkers.push({ - id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), - time: zcTime, - label: `Bar ${barMarkers.length + 1}`, - color: '#06b6d4' - }); - // Add beat markers within each bar - for (let b = 1; b < 4; b++) { - const bt = t + b * beatDuration; - if (bt < totalDuration) { - const zcBt = findZeroCrossing(buffer, bt); - barMarkers.push({ - id: 'ai_beat_' + barMarkers.length + '_' + Date.now(), - time: zcBt, - label: `Beat ${b + 1}`, - color: '#a855f7' - }); - } - } - } - setTracks(prev => prev.map(t => { - if (t.id !== activeTrack.id) return t; - const existingMarkers = t.markers || []; - return { - ...t, - markers: [...existingMarkers, ...barMarkers] - }; - })); - - // Find the first strong beat to set as selection start - const firstBeat = barMarkers.length > 0 ? barMarkers[0].time : 0; - const secondBar = barMarkers.length > 4 ? barMarkers[Math.min(4, barMarkers.length - 1)].time : Math.min(totalDuration, firstBeat + barDuration); - setSelectionStart(firstBeat); - setSelectionEnd(secondBar); - setAnalysisState({ - status: `AI Analysic Loop: ${detectedBPM} BPM, ${barMarkers.length} markers (Bar/Beat)`, - data: { - bpm: detectedBPM, - bars: Math.floor(totalDuration / barDuration) - }, - isRunning: false - }); - showToast(`AI Analysic Loop: ${detectedBPM} BPM - ${Math.floor(totalDuration / barDuration)} bars detected`, "success"); - } catch (err) { - setAnalysisState({ - status: 'Lỗi AI Analysic Loop', - data: null, - isRunning: false - }); - showToast(err.message || 'Lỗi khi phân tích nhịp', 'error'); - } - }; - // ── Mark Selection ── - // ── Helper: set selection range from buffer (used by sub-tab AI) ── - const setSelectionRangeOnBuffer = (buffer, startTime, endTime) => { - setSelectionStart(startTime); - setSelectionEnd(endTime); - }; - - // Ref for forced trackId (used by sub-tab AI buttons, overrides selectedTrackId) - const subTabAiTrackIdRef = useRef(null); - const handleAIScan = async () => { - // Resolve track: prefer sub-tab override, then selectedTrackId - const forcedTrackId = subTabAiTrackIdRef.current; - subTabAiTrackIdRef.current = null; - const activeTrackId = forcedTrackId || selectedTrackId; - const activeTrack = tracks.find(t => t.id === activeTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track có âm thanh để AI quét Loop.", "warning"); - return; - } - setAnalysisState({ - status: 'AI Loop Scan đang quét nhịp điệu và phách mạnh...', - data: null, - isRunning: true - }); - showToast("AI Scan đang phân tích tempo và phách mạnh...", "info"); - try { - const buffer = activeTrack.buffer; - const data = buffer.getChannelData(0); - const sr = buffer.sampleRate; - const windowSize = Math.min(sr * 3, data.length); - - // Detect BPM via autocorrelation - let detectedBPM = 120; - if (windowSize > sr) { - let maxCorr = 0; - for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { - let corr = 0; - const step = 4; - for (let i = 0; i < windowSize && i + lag < data.length; i += step) { - corr += data[i] * data[i + lag]; - } - corr /= windowSize / step; - if (corr > maxCorr) { - maxCorr = corr; - detectedBPM = 60 / (lag / sr); - } - } - } - detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); - - // Calculate bar grid - const beatDuration = 60 / detectedBPM; - const barDuration = beatDuration * 4; - const totalDuration = buffer.duration; - - // Place markers at bar starts (strong beats / downbeats) - const barMarkers = []; - for (let t = 0; t < totalDuration; t += barDuration) { - const zcTime = findZeroCrossing(buffer, t); - barMarkers.push({ - id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), - time: zcTime, - label: `Downbeat ${barMarkers.length + 1}`, - color: '#06b6d4' - }); - } - setTracks(prev => prev.map(t => { - if (t.id !== activeTrack.id) return t; - const existingMarkers = t.markers || []; - return { - ...t, - markers: [...existingMarkers, ...barMarkers] - }; - })); - - // Set selection to the first downbeat - const firstBarStart = barMarkers.length > 0 ? barMarkers[0].time : 0; - const secondBarStart = barMarkers.length > 1 ? barMarkers[1].time : Math.min(totalDuration, firstBarStart + barDuration); - setSelectionStart(firstBarStart); - setSelectionEnd(secondBarStart); - setAnalysisState({ - status: `AI Scan: ${detectedBPM} BPM, ${barMarkers.length} downbeats (đã snap zero-crossing)`, - data: { - bpm: detectedBPM - }, - isRunning: false - }); - showToast(`AI Scan: ${detectedBPM} BPM - ${barMarkers.length} downbeats detected`, "success"); - } catch (err) { - setAnalysisState({ - status: 'Lỗi khi AI Scan', - data: null, - isRunning: false - }); - showToast(err.message || 'Lỗi khi quét AI Loop', 'error'); - } - }; - const runPythonTool = async toolType => { - const activeTrack = tracks.find(t => t.id === selectedTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track để xử lý công cụ Python.", "warning"); - return; - } - try { - if (toolType === 'normalize') { - const channelData = activeTrack.buffer.getChannelData(0); - let maxVal = 0; - for (let i = 0; i < channelData.length; i++) { - maxVal = Math.max(maxVal, Math.abs(channelData[i])); - } - if (maxVal > 0) { - const gain = 1.0 / maxVal; - for (let i = 0; i < channelData.length; i++) { - channelData[i] *= gain; - } - } - showToast("Đã Chuẩn Hóa Peak âm thanh về 0 dB!", "success"); - } else if (toolType === 'invert_phase') { - const channelData = activeTrack.buffer.getChannelData(0); - for (let i = 0; i < channelData.length; i++) { - channelData[i] *= -1; - } - showToast("Đã Đảo Pha (180°) âm thanh thành công!", "success"); - } else if (toolType === 'swap_channels') { - if (activeTrack.buffer.numberOfChannels >= 2) { - const left = activeTrack.buffer.getChannelData(0); - const right = activeTrack.buffer.getChannelData(1); - for (let i = 0; i < left.length; i++) { - const temp = left[i]; - left[i] = right[i]; - right[i] = temp; - } - showToast("Đã Đổi Kênh Left / Right thành công!", "success"); - } else { - showToast("Track hiện tại là Mono. Chỉ áp dụng Đổi Kênh cho Stereo.", "info"); - } - } else if (toolType === 'synth_wave') { - generateSynthToTrack(activeTrack.id, 'synth'); - showToast("Đã tạo Tín Hiệu Sóng Tổng Hợp bằng công cụ Python!", "success"); - } - } catch (err) { - showToast(err.message || "Lỗi khi chạy công cụ Python", "error"); - } - }; - const handleMarkSelection = () => { - if (selLeft === null || selRight === null || selectionStats.length === 0) { - showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.", "warning"); - return; - } - const targetTrack = tracks.find(t => t.id === selectedTrackId); - if (!targetTrack || !targetTrack.buffer) { - showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.", "warning"); - return; - } - - // For local selection, force mark on the local-selected track - const markTrackId = selectionMode === 'local' && localSelectionTrackId ? localSelectionTrackId : selectedTrackId; - setTracks(prev => prev.map(t => { - if (t.id !== markTrackId) return t; - const snapStart = findZeroCrossing(t.buffer, selLeft); - const snapEnd = findZeroCrossing(t.buffer, selRight); - const newMarkers = [...t.markers, { - id: Date.now() + '_s', - time: snapStart - }, { - id: Date.now() + '_e', - time: snapEnd - }].sort((a, b) => a.time - b.time); - return { - ...t, - markers: newMarkers - }; - })); - showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`, "success"); - }; - - // ── AI Cut to New Track (Music Theory Loop Detection) ── - const handleAICutToNewTrack = () => { - const forcedTrackId = subTabAiTrackIdRef.current; - subTabAiTrackIdRef.current = null; - const activeTrackId = forcedTrackId || selectedTrackId; - const activeTrack = tracks.find(t => t.id === activeTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.", "warning"); - return; - } - if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) { - showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.", "warning"); - return; - } - setAnalysisState({ - status: 'AI Cut: đang phân tích nhịp và tìm loop point...', - data: null, - isRunning: true - }); - showToast("AI Cut: đang phân tích nhịp điệu và tìm điểm loop chính xác...", "info"); - setTimeout(() => { - try { - const buffer = activeTrack.buffer; - const sampleRate = buffer.sampleRate; - const channelData = buffer.getChannelData(0); - const dataLen = channelData.length; - const windowSize = Math.min(sampleRate * 3, dataLen); - - // Detect BPM via autocorrelation - let detectedBPM = 120; - if (windowSize > sampleRate) { - let maxCorr = 0; - for (let lag = Math.floor(sampleRate * 0.3); lag <= Math.floor(sampleRate * 2.0); lag++) { - let corr = 0; - const step = 4; - for (let i = 0; i < windowSize && i + lag < dataLen; i += step) { - corr += channelData[i] * channelData[i + lag]; - } - corr /= windowSize / step; - if (corr > maxCorr) { - maxCorr = corr; - detectedBPM = 60 / (lag / sampleRate); - } - } - } - detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); - const beatDuration = 60 / detectedBPM; - const barDuration = beatDuration * 4; - - // Use the selection range - const rawStart = selectionStats.start; - const rawEnd = selectionStats.end; - const selDuration = rawEnd - rawStart; - - // Find the nearest bar start (downbeat) for loop start - const barsFromZero = rawStart / barDuration; - const nearestBarStart = Math.round(barsFromZero) * barDuration; - const loopStart = Math.max(0, Math.min(rawStart + barDuration, nearestBarStart)); - - // Find the nearest beat 4 (bar end) for loop end - // In 4/4 time: beat 4 = barStart + 3*beatDuration = barEnd - const barsFromStart = rawEnd / barDuration; - const nearestBarEnd = Math.round(barsFromStart) * barDuration; - // Ensure minimum 1 bar loop - let loopEnd = Math.max(loopStart + barDuration, nearestBarEnd); - if (loopEnd > rawEnd + beatDuration) loopEnd = loopStart + Math.ceil(selDuration / barDuration) * barDuration; - - // Snap to zero-crossing for click-free loop - const snapLoopStart = findZeroCrossing(buffer, loopStart); - const snapLoopEnd = findZeroCrossing(buffer, loopEnd); - - // Place markers for the loop points - setTracks(prev => prev.map(t => { - if (t.id !== activeTrack.id) return t; - const existingMarkers = t.markers || []; - const filtered = existingMarkers.filter(m => !m.id.startsWith('ai_loop_')); - return { - ...t, - markers: [...filtered, { - id: 'ai_loop_start_' + Date.now(), - time: snapLoopStart, - label: 'Loop Start (Bar ' + (Math.floor(snapLoopStart / barDuration) + 1) + ')', - color: '#06b6d4' - }, { - id: 'ai_loop_end_' + Date.now(), - time: snapLoopEnd, - label: 'Loop End (Beat 4)', - color: '#a855f7' - }] - }; - })); - setSelectionStart(snapLoopStart); - setSelectionEnd(snapLoopEnd); - const startSample = Math.max(0, Math.min(dataLen - 1, Math.floor(snapLoopStart * sampleRate))); - const endSample = Math.max(0, Math.min(dataLen, Math.floor(snapLoopEnd * sampleRate))); - const sliceLength = endSample - startSample; - if (sliceLength <= 0) { - showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.", "error"); - setAnalysisState({ - status: 'Thất bại', - data: null, - isRunning: false - }); - return; - } - const context = getAudioContext(); - const numChannels = buffer.numberOfChannels || 1; - const slicedBuffer = context.createBuffer(numChannels, sliceLength, sampleRate); - for (let c = 0; c < numChannels; c++) { - const srcData = buffer.getChannelData(c); - const dstData = slicedBuffer.getChannelData(c); - dstData.set(srcData.subarray(startSample, endSample)); - } - const newId = 'track_ai_cut_' + Date.now(); - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const selectColor = colors[tracks.length % colors.length]; - const barNum = Math.floor(snapLoopStart / barDuration) + 1; - const barsCount = Math.max(1, Math.round((snapLoopEnd - snapLoopStart) / barDuration)); - const newTrack = { - id: newId, - name: `Loop_${barNum}bar_${activeTrack.name.replace('.wav', '').slice(0, 10)}_${snapLoopStart.toFixed(1)}s.wav`, - buffer: slicedBuffer, - channelInfo: activeTrack.channelInfo ? { - ...activeTrack.channelInfo - } : null, - startTime: snapLoopStart, - clips: [{ - id: 'clip_' + newId, - buffer: slicedBuffer, - startTime: snapLoopStart, - name: `Loop_${barNum}bar_${snapLoopStart.toFixed(1)}s` - }], - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: selectColor, - markers: [{ - id: Date.now() + '_s', - time: 0 - }, { - id: Date.now() + '_e', - time: snapLoopEnd - snapLoopStart - }], - serverFileId: null - }; - setTracks(prev => { - const idx = prev.findIndex(t => t.id === selectedTrackId); - const updated = [...prev]; - if (idx !== -1) { - updated.splice(idx + 1, 0, newTrack); - } else { - updated.push(newTrack); - } - return updated; - }); - setSelectedTrackId(newId); - setAnalysisState({ - status: `AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`, - data: { - bpm: detectedBPM, - bars: barsCount, - timeSig: '4/4' - }, - isRunning: false - }); - showToast(`AI Cut: ${barsCount} bars loop at ${snapLoopStart.toFixed(3)}s - ${snapLoopEnd.toFixed(3)}s [${detectedBPM} BPM]`, "success"); - setTimeout(() => lucide.createIcons(), 200); - } catch (err) { - showToast("Lỗi khi AI Cut: " + err.message, "error"); - setAnalysisState({ - status: 'Lỗi AI Cut', - data: null, - isRunning: false - }); - } - }, 800); - }; - - // ── AI Prompt Send to Active Provider ── - const handleAISend = async () => { - const prompt = aiPrompt.trim(); - if (!prompt) { - showToast('Vui lòng nhập nội dung prompt.', 'warning'); - return; - } - if (window.DAWCommandDispatcher) { - window.DAWCommandDispatcher.currentSelectedTrackId = selectedTrackId; - window.DAWCommandDispatcher.currentTracks = tracks; - window.DAWCommandDispatcher.lastCutSourceTrackId = null; - window.DAWCommandDispatcher.lastCutNewTrackId = null; - } - setAiProcessing(true); - setAiActionLog(prev => [...prev, { - type: 'status', - text: ` ⏳ Đang gửi prompt đến AI...`, - time: Date.now() - }]); - try { - if (aiProviders.length === 0 || !selectedProviderId) { - try { - const data = await window.SonicAPI.getAIConfigs(); - if (data && data.providers && data.providers.length > 0) { - setAiProviders(data.providers); - const active = data.providers.find(p => p.is_active) || data.providers[0]; - if (active) setSelectedProviderId(active.id); - } - } catch (e) {} - } - const prv = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null); - const provider = prv || aiConfig; - const baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`; - const apiKey = provider.api_key || provider.apiKey || ''; - const model = provider.model_name || provider.model || 'deepseek-chat'; - setAiActionLog(prev => [...prev, { - type: 'info', - text: ` Provider: ${provider.name || 'default'} | Model: ${model} | URL: ${baseUrl.slice(0, 40)}`, - time: Date.now() - }]); - const dawContext = window.AIGateway.buildAIPromptContext({ - tracks, - bpm, - selectedTrackId, - currentTime, - selLeft, - selRight - }); - const result = await window.AIGateway.executeAIPrompt({ - prompt: prompt, - provider: provider.name || 'default', - model: model, - apiKey: apiKey, - baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), - dawContext, - tools: window.AIGateway.DEFAULT_TOOLS - }); - const hasText = !!result.textResponse; - const hasCalls = result.functionCalls && result.functionCalls.length > 0; - if (hasText) { - setAiActionLog(prev => [...prev, { - type: 'status', - text: ` AI: ${result.textResponse.slice(0, 500)}`, - time: Date.now() - }]); - } - if (hasCalls) { - setAiActionLog(prev => [...prev, { - type: 'info', - text: ` Gọi ${result.functionCalls.length} lệnh...`, - time: Date.now() - }]); - if (window.DAWCommandDispatcher) { - window.DAWCommandDispatcher.isExecutingAI = true; - } - try { - for (const fc of result.functionCalls) { - setAiActionLog(prev => [...prev, { - type: 'info', - text: ` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`, - time: Date.now() - }]); - const cmdName = fc.name.toUpperCase(); - if (window.DAWCommandDispatcher) { - try { - let cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); - if (cmdResult && typeof cmdResult.then === 'function') cmdResult = await cmdResult; - setAiActionLog(prev => [...prev, { - type: 'status', - text: ` ✅ ${fc.name}: ${cmdResult && cmdResult.success ? 'thành công' : 'thất bại: ' + (cmdResult && cmdResult.error || 'unknown')}`, - time: Date.now() - }]); - } catch (cmdErr) { - setAiActionLog(prev => [...prev, { - type: 'error', - text: ` ❌ ${fc.name}: ${cmdErr.message}`, - time: Date.now() - }]); - } - } else { - setAiActionLog(prev => [...prev, { - type: 'error', - text: ` ❌ ${fc.name}: DAWCommandDispatcher not available`, - time: Date.now() - }]); - } - } - } finally { - if (window.DAWCommandDispatcher) { - window.DAWCommandDispatcher.isExecutingAI = false; - } - } - } - if (!hasText && !hasCalls) { - const rawKeys = result.raw ? Object.keys(result.raw).join(', ') : 'null'; - const errDetail = result.raw && result.raw.error ? ` (${result.raw.error.message || result.raw.error})` : ''; - setAiActionLog(prev => [...prev, { - type: 'error', - text: ` AI không trả về lệnh hoặc text. Keys: [${rawKeys}]${errDetail}`, - time: Date.now() - }]); - } - if (prompt) { - promptHistRef.current = [...promptHistRef.current.slice(-49), prompt]; - setPromptHistory(promptHistRef.current); - } - setPromptHistIdx(-1); - setAiActionLog(prev => [...prev, { - type: 'status', - text: ` Hoàn tất.`, - time: Date.now() - }]); - setAiPrompt(''); - setTimeout(() => lucide.createIcons(), 200); - } catch (err) { - setAiActionLog(prev => [...prev, { - type: 'error', - text: ` Lỗi: ${err.message}`, - time: Date.now() - }]); - showToast(`AI Error: ${err.message}`, 'error'); - } finally { - setAiProcessing(false); - } - }; - - // ── Split Track at Playhead ── - const handleSplitTrackAtTime = (trackId, clipId, time) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []; - const targetClipId = clipId || clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id; - if (!targetClipId) return; - const clip = clips.find(c => c.id === targetClipId); - if (!clip || !clip.buffer) return; - const relTime = Math.max(0, time - clip.startTime); - const sr = clip.buffer.sampleRate; - const cutSample = Math.floor(relTime * sr); - const originalData = clip.buffer.getChannelData(0); - if (cutSample <= 0 || cutSample >= originalData.length) { - showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning"); - return; - } - const beforeSnap = captureTrackSnapshot(trackId); - const ctx = getAudioContext(); - const b1 = ctx.createBuffer(1, cutSample, sr); - b1.copyToChannel(originalData.subarray(0, cutSample), 0); - const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr); - b2.copyToChannel(originalData.subarray(cutSample), 0); - const clip1 = { - id: 'clip_' + Date.now() + '_p1', - name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`, - buffer: b1, - startTime: clip.startTime - }; - const clip2 = { - id: 'clip_' + Date.now() + '_p2', - name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`, - buffer: b2, - startTime: clip.startTime + cutSample / sr - }; - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - const remainingClips = clips.filter(c => c.id !== targetClipId); - const updatedClips = [...remainingClips, clip1, clip2]; - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer || null, - startTime: updatedClips[0]?.startTime || 0, - name: updatedClips[0]?.name || t.name - }; - } - return t; - })); - setTimeout(() => { - const afterSnap = captureTrackSnapshot(trackId); - pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap); - }, 50); - showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info"); - }; - const handleSplitTrack = trackId => { - handleSplitTrackAtTime(trackId, null, currentTime); - }; - - // ── Glue (Merge) Clips on Selected Track ── - const handleGlueTracks = () => { - const track = tracks.find(t => t.id === selectedTrackId); - if (!track) { - showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning'); - return; - } - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []; - if (clips.length < 2) { - showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning'); - return; - } - const beforeSnap = captureTrackSnapshot(track.id); - const ctx = getAudioContext(); - const sr = clips[0].buffer.sampleRate; - let minStart = Infinity; - let maxEnd = -Infinity; - clips.forEach(c => { - const start = c.startTime || 0; - const end = start + c.buffer.duration; - minStart = Math.min(minStart, start); - maxEnd = Math.max(maxEnd, end); - }); - const newDur = maxEnd - minStart; - const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr); - const newData = newBuffer.getChannelData(0); - clips.forEach(c => { - const data = c.buffer.getChannelData(0); - const offset = Math.floor(((c.startTime || 0) - minStart) * sr); - for (let i = 0; i < data.length; i++) { - if (offset + i < newData.length) { - newData[offset + i] += data[i]; - } - } - }); - let maxPeak = 0; - for (let i = 0; i < newData.length; i++) { - const abs = Math.abs(newData[i]); - if (abs > maxPeak) maxPeak = abs; - } - if (maxPeak > 1.0) { - for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak; - } - const mergedClip = { - id: 'clip_merged_' + Date.now(), - name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`, - buffer: newBuffer, - startTime: minStart - }; - setTracks(prev => prev.map(t => { - if (t.id === track.id) { - return { - ...t, - clips: [mergedClip], - buffer: newBuffer, - startTime: minStart, - name: mergedClip.name - }; - } - return t; - })); - setTimeout(() => { - const afterSnap = captureTrackSnapshot(track.id); - pushAction('GLUE', track.id, beforeSnap, afterSnap); - }, 50); - showToast(`Đã gộp ${clips.length} clips thành công.`, 'success'); - }; - - // ── DAW Command Registration for AI (28_AI_PANEL.md §1 & §2) ── - useEffect(() => { - if (typeof window.DAWCommandDispatcher === 'undefined') return; - const api = { - createTrack: args => { - const name = args.name || `AI_Track_${Date.now()}`; - const type = args.type || 'audio'; - const newId = addNewTrack(); - if (name && name !== `AI_Track_${Date.now()}`) { - updateTrackName(newId, name); - } - return { - success: true, - trackId: newId, - name - }; - }, - deleteTrack: args => { - const tid = args.track_id || selectedTrackId; - if (!tid) return { - success: false, - error: 'No track_id provided' - }; - deleteTrack(tid); - return { - success: true, - trackId: tid - }; - }, - addClip: args => { - const trackId = args.track_id || selectedTrackId; - const barDur = 60 / parseInt(bpm || 120) * 4; - let startTime; - if (args.start_time !== undefined && args.start_time !== null) startTime = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) startTime = args.start_bar * barDur;else startTime = currentTime; - const track = tracks.find(t => t.id === trackId); - if (!track) return { - success: false, - error: 'Track not found' - }; - const ctx = getAudioContext(); - const sr = 44100; - let duration; - if (args.duration_seconds !== undefined && args.duration_seconds !== null) duration = args.duration_seconds;else if (args.length_bars !== undefined && args.length_bars !== null) duration = args.length_bars * barDur;else duration = 2; - const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); - const data = buffer.getChannelData(0); - for (let i = 0; i < data.length; i++) data[i] = 0; - const clipId = nextClipId(); - setTracks(prev => prev.map(t => { - if (t.id !== trackId) return t; - const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []; - return { - ...t, - clips: [...clips, { - id: clipId, - buffer, - startTime, - name: args.name || 'AI Clip' - }], - buffer: clips.length > 0 ? clips[0].buffer : buffer, - startTime: clips.length > 0 ? clips[0].startTime : startTime, - name: clips.length > 0 ? clips[0].name : args.name || t.name - }; - })); - return { - success: true, - clipId, - trackId - }; - }, - removeClip: args => { - const trackId = args.track_id || selectedTrackId; - const clipId = args.clip_id; - setTracks(prev => prev.map(t => { - if (t.id !== trackId) return t; - const updatedClips = (t.clips || []).filter(c => c.id !== clipId); - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer || null, - startTime: updatedClips[0]?.startTime || 0, - name: updatedClips[0]?.name || t.name - }; - })); - return { - success: true - }; - }, - setTrackVolume: args => { - const trackId = args.track_id || selectedTrackId; - const vol = args.volume_db ?? args.volume ?? 0; - updateTrackVolumeDb(trackId, parseFloat(vol)); - return { - success: true, - trackId, - volumeDb: vol - }; - }, - setTrackPan: args => { - const trackId = args.track_id || selectedTrackId; - const pan = args.pan ?? 0; - updateTrackPan(trackId, parseInt(pan)); - return { - success: true, - trackId, - pan - }; - }, - toggleMute: args => { - const trackId = args.track_id || selectedTrackId; - toggleTrackMute(trackId); - const track = tracks.find(t => t.id === trackId); - return { - success: true, - trackId, - muted: track ? track.muted : null - }; - }, - toggleSolo: args => { - const trackId = args.track_id || selectedTrackId; - toggleTrackSoloEvaluate(trackId); - const track = tracks.find(t => t.id === trackId); - return { - success: true, - trackId, - solo: track ? track.solo : null - }; - }, - processAudioDsp: args => { - const trackId = args.track_id || selectedTrackId; - const action = args.action; - const params = args.params || {}; - const track = tracks.find(t => t.id === trackId); - if (!track || !track.buffer) return { - success: false, - error: 'Track has no audio buffer' - }; - if (action === 'normalize') { - const channelData = track.buffer.getChannelData(0); - let maxVal = 0; - for (let i = 0; i < channelData.length; i++) maxVal = Math.max(maxVal, Math.abs(channelData[i])); - if (maxVal > 0) { - const gain = 1.0 / maxVal; - for (let i = 0; i < channelData.length; i++) channelData[i] *= gain; - } - return { - success: true, - action: 'normalize' - }; - } else if (action === 'invert_phase') { - const channelData = track.buffer.getChannelData(0); - for (let i = 0; i < channelData.length; i++) channelData[i] *= -1; - return { - success: true, - action: 'invert_phase' - }; - } else if (action === 'gain') { - const gainDb = params.gain_db ?? 0; - const scale = Math.pow(10, gainDb / 20); - const channelData = track.buffer.getChannelData(0); - for (let i = 0; i < channelData.length; i++) channelData[i] = Math.max(-1, Math.min(1, channelData[i] * scale)); - return { - success: true, - action: 'gain', - gainDb - }; - } else if (action === 'pitch_shift') { - const semitones = params.semitones ?? 0; - const ratio = Math.pow(2, semitones / 12); - const applyResample = (data, r) => { - const newLen = Math.round(data.length * r); - const out = new Float32Array(newLen); - for (let i = 0; i < newLen; i++) { - const srcIdx = i / r; - const idx0 = Math.floor(srcIdx); - const idx1 = Math.min(idx0 + 1, data.length - 1); - const frac = srcIdx - idx0; - out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; - } - return out; - }; - const channelData = track.buffer.getChannelData(0); - const newData = applyResample(channelData, 1 / ratio); - const ctx = getAudioContext(); - const newBuffer = ctx.createBuffer(1, newData.length, track.buffer.sampleRate); - newBuffer.copyToChannel(newData, 0); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - buffer: newBuffer - } : t)); - return { - success: true, - action: 'pitch_shift', - semitones - }; - } - return { - success: false, - error: `Unknown action: ${action}` - }; - }, - renameTrack: args => { - const tid = args.track_id || selectedTrackId; - const name = args.name; - if (!tid) return { - success: false, - error: 'No track_id provided' - }; - if (!name) return { - success: false, - error: 'No name provided' - }; - updateTrackName(tid, name); - return { - success: true, - trackId: tid, - name - }; - }, - setSelection: args => { - const barDur = 60 / parseInt(bpm || 120) * 4; - let start, end; - if (args.start_time !== undefined && args.start_time !== null) start = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) start = args.start_bar * barDur;else start = currentTime; - if (args.end_time !== undefined && args.end_time !== null) end = args.end_time;else if (args.length_bars !== undefined && args.length_bars !== null) end = start + args.length_bars * barDur;else if (args.end_bar !== undefined && args.end_bar !== null) end = args.end_bar * barDur;else end = start + barDur; - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(start); - setSelectionEnd(end); - selectionRef.current = { - start, - end - }; - return { - success: true, - start: parseFloat(start.toFixed(3)), - end: parseFloat(end.toFixed(3)), - length: parseFloat((end - start).toFixed(3)) - }; - }, - cutAudio: args => { - const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; - const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; - const tid = args.track_id || currentSelTrackId; - const track = currentTracks.find(t => t.id === String(tid)); - if (!track) return { - success: false, - error: 'Track not found' - }; - if (!track.buffer) return { - success: false, - error: 'Track has no audio buffer' - }; - const barDur = 60 / parseInt(bpm || 120) * 4; - const sel = selectionRef.current; - let rawStart, rawEnd; - if (args.start_time !== undefined && args.start_time !== null) rawStart = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) rawStart = args.start_bar * barDur;else if (sel.start !== null) rawStart = sel.start;else rawStart = currentTime; - if (args.end_time !== undefined && args.end_time !== null) rawEnd = args.end_time;else if (args.end_bar !== undefined && args.end_bar !== null) rawEnd = args.end_bar * barDur;else if (args.length_bars !== undefined && args.length_bars !== null) rawEnd = rawStart + args.length_bars * barDur;else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end;else return { - success: false, - error: 'No end position provided. Provide end_time, end_bar, or length_bars.' - }; - if (rawEnd <= rawStart) return { - success: false, - error: 'End position must be after start position.' - }; - const buffer = track.buffer; - const ctx = getAudioContext(); - const snap = args.snap_silence !== false; - const loopStart = snap ? findZeroCrossing(buffer, rawStart) : rawStart; - const loopEnd = snap ? findZeroCrossing(buffer, rawEnd) : rawEnd; - const sampleRate = buffer.sampleRate; - const startSample = Math.max(0, Math.min(buffer.length - 1, Math.floor(loopStart * sampleRate))); - const endSample = Math.max(0, Math.min(buffer.length, Math.floor(loopEnd * sampleRate))); - const sliceLength = endSample - startSample; - if (sliceLength <= 100) return { - success: false, - error: 'Selection too short or invalid' - }; - const numChannels = buffer.numberOfChannels || 1; - const slicedBuffer = ctx.createBuffer(numChannels, sliceLength, sampleRate); - for (let c = 0; c < numChannels; c++) { - const src = buffer.getChannelData(c); - const dst = slicedBuffer.getChannelData(c); - dst.set(src.subarray(startSample, endSample)); - } - const newId = 'track_cut_' + Date.now(); - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const color = colors[currentTracks.length % colors.length]; - const cutName = args.new_track_name || `Cut_${track.name}`; - const newTrack = { - id: newId, - name: cutName, - buffer: slicedBuffer, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color, - markers: [], - clips: [{ - id: 'clip_' + newId, - buffer: slicedBuffer, - startTime: 0, - name: cutName - }], - serverFileId: null - }; - const idx = currentTracks.findIndex(t => t.id === tid); - const nextTracks = [...currentTracks]; - if (idx !== -1) { - nextTracks.splice(idx + 1, 0, newTrack); - } else { - nextTracks.push(newTrack); - } - if (window.DAWCommandDispatcher) { - window.DAWCommandDispatcher.currentTracks = nextTracks; - window.DAWCommandDispatcher.currentSelectedTrackId = newId; - window.DAWCommandDispatcher.lastCutSourceTrackId = tid; - window.DAWCommandDispatcher.lastCutNewTrackId = newId; - } - setTracks(nextTracks); - setSelectedTrackId(newId); - selectionRef.current = { - start: 0, - end: slicedBuffer.duration - }; - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(0); - setSelectionEnd(slicedBuffer.duration); - setTimeout(() => lucide.createIcons(), 200); - return { - success: true, - trackId: newId, - trackName: cutName, - cutStart: parseFloat(loopStart.toFixed(3)), - cutEnd: parseFloat(loopEnd.toFixed(3)), - duration: parseFloat(slicedBuffer.duration.toFixed(3)) - }; - }, - scanTrack: args => { - const tid = args.track_id || selectedTrackId; - const track = tracks.find(t => t.id === tid); - if (!track) return { - success: false, - error: 'Track not found' - }; - if (!track.buffer) return { - success: false, - error: 'Track has no audio buffer. Load audio first.' - }; - const buffer = track.buffer; - const data = buffer.getChannelData(0); - const sr = buffer.sampleRate; - const channels = buffer.numberOfChannels; - const duration = buffer.duration; - const totalSamples = buffer.length; - const windowSize = Math.min(sr * 3, data.length); - let detectedBPM = 0; - if (windowSize > sr) { - let maxCorr = 0; - for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { - let corr = 0; - const step = 4; - for (let i = 0; i < windowSize && i + lag < data.length; i += step) corr += data[i] * data[i + lag]; - corr /= windowSize / step; - if (corr > maxCorr) { - maxCorr = corr; - detectedBPM = 60 / (lag / sr); - } - } - } - detectedBPM = Math.round(Math.min(300, Math.max(30, detectedBPM))); - if (args.set_tempo !== false && detectedBPM > 0) { - setBpm(String(detectedBPM)); - } - const bitDepth = 16; - const bitrate = Math.round(sr * channels * bitDepth / 1000); - return { - success: true, - trackId: tid, - trackName: track.name, - bpm: detectedBPM, - sampleRate: sr, - channels, - duration: parseFloat(duration.toFixed(3)), - totalSamples, - bitDepth, - bitrateKbps: bitrate, - hasAudio: true - }; - }, - setBpm: args => { - const bpmVal = args.bpm || args.tempo || 120; - setBpm(String(bpmVal)); - return { - success: true, - bpm: bpmVal - }; - }, - setPlayhead: args => { - const barDur = 60 / parseInt(bpm || 120) * 4; - let time; - if (args.time !== undefined && args.time !== null) time = args.time;else if (args.bar !== undefined && args.bar !== null) time = args.bar * barDur;else time = 0; - handlePlayheadSet(time); - return { - success: true, - time: parseFloat(time.toFixed(3)) - }; - }, - exportAudio: async args => { - const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; - const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; - let tid = args.track_id; - if (tid && window.DAWCommandDispatcher?.lastCutSourceTrackId && (String(tid) === String(window.DAWCommandDispatcher.lastCutSourceTrackId) || 'track_' + tid === String(window.DAWCommandDispatcher.lastCutSourceTrackId))) { - tid = window.DAWCommandDispatcher.lastCutNewTrackId; - } - if (!tid) tid = currentSelTrackId; - const track = tid && currentTracks.find(t => t.id === String(tid) || t.id === 'track_' + tid); - if (!track) return { - success: false, - error: 'No track found' - }; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []; - if (clips.length === 0) return { - success: false, - error: 'Track has no audio clips' - }; - const totalDuration = Math.max(...clips.map(c => (c.startTime || 0) + (c.buffer ? c.buffer.duration / (c.speed || 1.0) : 0))); - const barDur = 60 / parseInt(bpm || 120) * 4; - const sel = selectionRef.current; - let rawStart, rawEnd; - if (args.start_time !== undefined) rawStart = args.start_time;else if (args.start_bar !== undefined) rawStart = args.start_bar * barDur;else if (sel.start !== null) rawStart = sel.start;else rawStart = 0; - if (args.end_time !== undefined) rawEnd = args.end_time;else if (args.length_bars !== undefined) rawEnd = (rawStart || 0) + args.length_bars * barDur;else if (args.end_bar !== undefined) rawEnd = args.end_bar * barDur;else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end;else rawEnd = totalDuration; - if (rawEnd <= rawStart) return { - success: false, - error: 'Export range is empty or invalid.' - }; - const ctx = getAudioContext(); - const sr = parseInt(args.sample_rate || '44100'); - const firstBuffer = clips.find(c => c.buffer)?.buffer; - const numCh = args.channels === 'mono' ? 1 : firstBuffer ? firstBuffer.numberOfChannels : 2; - const bd = parseInt(args.bit_depth || '16'); - const fmt = args.format || 'wav'; - const renderLength = rawEnd - rawStart; - const offlineCtx = new OfflineAudioContext(numCh, Math.ceil(sr * renderLength), sr); - clips.forEach(clip => { - if (!clip.buffer) return; - const clipStart = clip.startTime || 0; - const clipDuration = clip.buffer.duration / (clip.speed || 1.0); - const clipEnd = clipStart + clipDuration; - if (clipEnd <= rawStart || clipStart >= rawEnd) return; - const source = offlineCtx.createBufferSource(); - source.buffer = clip.buffer; - source.playbackRate.value = clip.speed || 1.0; - source.connect(offlineCtx.destination); - if (rawStart < clipStart) { - const delay = clipStart - rawStart; - const playDur = Math.min(clipDuration, rawEnd - clipStart); - source.start(delay, 0, playDur * (clip.speed || 1.0)); - } else { - const offsetInClip = rawStart - clipStart; - const playDur = Math.min(clipEnd - rawStart, renderLength); - source.start(0, offsetInClip * (clip.speed || 1.0), playDur * (clip.speed || 1.0)); - } - }); - const renderedBuffer = await offlineCtx.startRendering(); - const len = renderedBuffer.length; - const bps = bd / 8; - const hdrSz = 44; - const fileBuf = new ArrayBuffer(hdrSz + len * bps * numCh); - const vw = new DataView(fileBuf); - const ws = (off, s) => { - for (let i = 0; i < s.length; i++) vw.setUint8(off + i, s.charCodeAt(i)); - }; - ws(0, 'RIFF'); - vw.setUint32(4, fileBuf.byteLength - 8, true); - ws(8, 'WAVE'); - ws(12, 'fmt '); - vw.setUint32(16, 16, true); - vw.setUint16(20, 1, true); - vw.setUint16(22, numCh, true); - vw.setUint32(24, sr, true); - vw.setUint32(28, sr * bps * numCh, true); - vw.setUint16(32, bps * numCh, true); - vw.setUint16(34, bd, true); - ws(36, 'data'); - vw.setUint32(40, len * bps * numCh, true); - let ofs = 44; - for (let i = 0; i < len; i++) { - for (let ch = 0; ch < numCh; ch++) { - const smp = Math.max(-1, Math.min(1, renderedBuffer.getChannelData(ch)[i])); - if (bd === 8) vw.setUint8(ofs, Math.floor((smp + 1) * 127.5));else if (bd === 16) vw.setInt16(ofs, Math.floor(smp < 0 ? smp * 0x8000 : smp * 0x7FFF), true);else { - const v24 = Math.floor(smp < 0 ? smp * 0x800000 : smp * 0x7FFFFF); - vw.setUint8(ofs, v24 & 0xFF); - vw.setUint8(ofs + 1, v24 >> 8 & 0xFF); - vw.setUint8(ofs + 2, v24 >> 16 & 0xFF); - } - ofs += bps; - } - } - const blob = new Blob([fileBuf], { - type: 'audio/wav' - }); - const localUrl = URL.createObjectURL(blob); - const targetFilename = `export_${Date.now()}.${fmt}`; - const triggerDownload = (downloadUrl, finalFilename) => { - if (window.DAWCommandDispatcher?.isExecutingAI) { - showToast(`Xuất nhạc thành công (${fmt.toUpperCase()})!`, "success", "Tải về", () => { - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = finalFilename; - a.click(); - if (downloadUrl.startsWith('blob:')) { - URL.revokeObjectURL(downloadUrl); - } - }); - } else { - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = finalFilename; - a.click(); - showToast("Xuất bản âm thanh hoàn tất!", "success"); - if (downloadUrl.startsWith('blob:')) { - URL.revokeObjectURL(downloadUrl); - } - } - }; - if ((fmt === 'mp3' || fmt === 'ogg') && serverStatus === 'connected') { - try { - const file = new File([blob], `export_ai.wav`, { - type: 'audio/wav' - }); - const formData = new FormData(); - formData.append('file', file); - const uploadResp = await fetch(`${API_AUDIO}/upload`, { - method: 'POST', - body: formData - }); - if (!uploadResp.ok) throw new Error("Upload failed"); - const uploadData = await uploadResp.json(); - const uploadId = uploadData.file_id; - const exportResp = await fetch(`${API_AUDIO}/export`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_id: uploadId, - format: fmt, - sample_rate: sr, - bit_depth: bd - }) - }); - if (!exportResp.ok) throw new Error("Export failed"); - const exportData = await exportResp.json(); - const result = await pollTaskResult(exportData.task_id, 20); - if (result.success) { - const downloadUrl = `${API_AUDIO}/download/${result.output_file_id}`; - triggerDownload(downloadUrl, targetFilename); - } else { - throw new Error(result.error || 'Server encoding failed'); - } - } catch (transcodeErr) { - showToast(`Lỗi chuyển đổi: ${transcodeErr.message}. Tải về dạng WAV thay thế.`, "warning"); - triggerDownload(localUrl, `export_${Date.now()}.wav`); - } - } else { - const finalFilename = fmt === 'wav' ? `export_${Date.now()}.wav` : `export_${Date.now()}.wav`; - if (fmt !== 'wav') { - showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.", "warning"); - } - triggerDownload(localUrl, finalFilename); - } - return { - success: true, - trackId: tid, - range: parseFloat((rawEnd - rawStart).toFixed(3)) + 's', - format: fmt, - channels: numCh === 1 ? 'mono' : 'stereo' - }; - }, - selectItem: args => { - if (args.select_all) { - setSelectedTrackId(null); - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(0); - const maxDur = tracks.reduce((max, t) => { - const dur = t.buffer ? t.buffer.duration : 0; - const clips = t.clips || []; - const clipMax = clips.reduce((m, c) => Math.max(m, (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0)), 0); - return Math.max(max, dur, clipMax); - }, 0); - setSelectionEnd(Math.max(maxDur, currentTime + 10)); - return { - success: true, - selection: 'all', - duration: parseFloat(Math.max(maxDur, currentTime + 10).toFixed(3)) - }; - } - const tid = args.track_id || selectedTrackId; - const track = tracks.find(t => t.id === tid); - if (!track) return { - success: false, - error: `Track ${tid} not found` - }; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default_' + track.id, - name: track.name, - buffer: track.buffer, - startTime: track.startTime || 0 - }] : []; - if (args.item_name) { - const match = clips.find(c => c.name && c.name.toLowerCase().includes(args.item_name.toLowerCase())); - if (!match) return { - success: false, - error: `No clip matching "${args.item_name}" on track ${track.name}` - }; - setSelectedTrackId(tid); - clearLocalSelection(); - setSelectionMode('global'); - const start = match.startTime || 0; - const end = start + (match.buffer ? match.buffer.duration : 2); - setSelectionStart(start); - setSelectionEnd(end); - return { - success: true, - trackId: tid, - trackName: track.name, - clipId: match.id, - clipName: match.name, - start: parseFloat(start.toFixed(3)), - end: parseFloat(end.toFixed(3)) - }; - } - setSelectedTrackId(tid); - clearLocalSelection(); - setSelectionMode('global'); - const trackEnd = track.buffer ? track.buffer.duration : clips.length > 0 ? Math.max(...clips.map(c => (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0))) : 4; - setSelectionStart(0); - setSelectionEnd(trackEnd); - return { - success: true, - trackId: tid, - trackName: track.name, - duration: parseFloat(trackEnd.toFixed(3)) - }; - }, - addMarker: args => { - const trackId = args.track_id || selectedTrackId; - const time = args.time ?? currentTime; - const track = tracks.find(t => t.id === trackId); - if (!track) return { - success: false, - error: 'Track not found' - }; - setTracks(prev => prev.map(t => { - if (t.id !== trackId) return t; - return { - ...t, - markers: [...(t.markers || []), { - id: 'ai_marker_' + Date.now(), - time, - label: args.label || 'AI Marker' - }] - }; - })); - return { - success: true, - trackId, - time - }; - }, - fadeIn: args => { - const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; - const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; - let trackIdRaw = args.track_id; - if (trackIdRaw && window.DAWCommandDispatcher?.lastCutSourceTrackId && (String(trackIdRaw) === String(window.DAWCommandDispatcher.lastCutSourceTrackId) || 'track_' + trackIdRaw === String(window.DAWCommandDispatcher.lastCutSourceTrackId))) { - trackIdRaw = window.DAWCommandDispatcher.lastCutNewTrackId; - } - if (!trackIdRaw) trackIdRaw = currentSelTrackId; - const track = currentTracks.find(t => t.id === String(trackIdRaw) || t.id === 'track_' + trackIdRaw); - if (!track) return { - success: false, - error: `Track ${trackIdRaw} not found` - }; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []; - if (clips.length === 0) return { - success: false, - error: 'No clips on track' - }; - let clip = null; - if (args.clip_id) { - clip = clips.find(c => c.id === args.clip_id); - } else if (args.clip_index !== undefined) { - const idx = parseInt(args.clip_index); - const realIdx = idx > 0 ? idx - 1 : 0; - clip = clips[realIdx] || clips[0]; - } else { - clip = clips[0]; - } - if (!clip || !clip.buffer) return { - success: false, - error: 'Clip has no audio buffer' - }; - const duration = parseFloat(args.duration_seconds || 3); - const buffer = clip.buffer; - const sr = buffer.sampleRate; - const numChannels = buffer.numberOfChannels; - const length = buffer.length; - const ctx = getAudioContext(); - const newBuffer = ctx.createBuffer(numChannels, length, sr); - for (let c = 0; c < numChannels; c++) { - newBuffer.getChannelData(c).set(buffer.getChannelData(c)); - } - const fadeSamples = Math.min(length, Math.floor(duration * sr)); - for (let c = 0; c < numChannels; c++) { - const data = newBuffer.getChannelData(c); - for (let i = 0; i < fadeSamples; i++) { - const factor = (1 - Math.cos(Math.PI * i / fadeSamples)) / 2; - data[i] *= factor; - } - } - const beforeSnap = captureTrackSnapshot(track.id); - const nextTracks = currentTracks.map(t => { - if (t.id !== track.id) return t; - const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []; - const updatedClips = existingClips.map(c => { - if (c.id === clip.id || clip.id.startsWith('default_') && c.id === 'default') { - return { - ...c, - buffer: newBuffer - }; - } - return c; - }); - const mainBuffer = updatedClips[0]?.buffer || t.buffer; - return { - ...t, - clips: updatedClips, - buffer: mainBuffer - }; - }); - if (window.DAWCommandDispatcher) { - window.DAWCommandDispatcher.currentTracks = nextTracks; - } - setTracks(nextTracks); - setTimeout(() => { - const afterSnap = captureTrackSnapshot(track.id); - pushAction('AI_FADE_IN', track.id, beforeSnap, afterSnap); - }, 50); - return { - success: true, - trackId: track.id, - clipId: clip.id, - duration_seconds: duration - }; - }, - fadeOut: args => { - const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; - const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; - let trackIdRaw = args.track_id; - if (trackIdRaw && window.DAWCommandDispatcher?.lastCutSourceTrackId && (String(trackIdRaw) === String(window.DAWCommandDispatcher.lastCutSourceTrackId) || 'track_' + trackIdRaw === String(window.DAWCommandDispatcher.lastCutSourceTrackId))) { - trackIdRaw = window.DAWCommandDispatcher.lastCutNewTrackId; - } - if (!trackIdRaw) trackIdRaw = currentSelTrackId; - const track = currentTracks.find(t => t.id === String(trackIdRaw) || t.id === 'track_' + trackIdRaw); - if (!track) return { - success: false, - error: `Track ${trackIdRaw} not found` - }; - const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []; - if (clips.length === 0) return { - success: false, - error: 'No clips on track' - }; - let clip = null; - if (args.clip_id) { - clip = clips.find(c => c.id === args.clip_id); - } else if (args.clip_index !== undefined) { - const idx = parseInt(args.clip_index); - const realIdx = idx > 0 ? idx - 1 : 0; - clip = clips[realIdx] || clips[0]; - } else { - clip = clips[0]; - } - if (!clip || !clip.buffer) return { - success: false, - error: 'Clip has no audio buffer' - }; - const duration = parseFloat(args.duration_seconds || 3); - const buffer = clip.buffer; - const sr = buffer.sampleRate; - const numChannels = buffer.numberOfChannels; - const length = buffer.length; - const ctx = getAudioContext(); - const newBuffer = ctx.createBuffer(numChannels, length, sr); - for (let c = 0; c < numChannels; c++) { - newBuffer.getChannelData(c).set(buffer.getChannelData(c)); - } - const fadeSamples = Math.min(length, Math.floor(duration * sr)); - for (let c = 0; c < numChannels; c++) { - const data = newBuffer.getChannelData(c); - for (let i = 0; i < fadeSamples; i++) { - const idx = length - fadeSamples + i; - const factor = (1 + Math.cos(Math.PI * i / fadeSamples)) / 2; - data[idx] *= factor; - } - } - const beforeSnap = captureTrackSnapshot(track.id); - const nextTracks = currentTracks.map(t => { - if (t.id !== track.id) return t; - const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []; - const updatedClips = existingClips.map(c => { - if (c.id === clip.id || clip.id.startsWith('default_') && c.id === 'default') { - return { - ...c, - buffer: newBuffer - }; - } - return c; - }); - const mainBuffer = updatedClips[0]?.buffer || t.buffer; - return { - ...t, - clips: updatedClips, - buffer: mainBuffer - }; - }); - if (window.DAWCommandDispatcher) { - window.DAWCommandDispatcher.currentTracks = nextTracks; - } - setTracks(nextTracks); - setTimeout(() => { - const afterSnap = captureTrackSnapshot(track.id); - pushAction('AI_FADE_OUT', track.id, beforeSnap, afterSnap); - }, 50); - return { - success: true, - trackId: track.id, - clipId: clip.id, - duration_seconds: duration - }; - } - }; - window.DAWCommandDispatcher.registerDAWCommands(api); - }, [tracks, selectedTrackId, currentTime, bpm]); - - // ── Save AI config to localStorage ── - useEffect(() => { - localStorage.setItem('ai_base_url', aiConfig.baseUrl); - localStorage.setItem('ai_api_key', aiConfig.apiKey); - localStorage.setItem('ai_model', aiConfig.model); - }, [aiConfig]); - - // ── Auto-save user preferences (panel state, provider) ── - const prefsRef = useRef({}); - prefsRef.current = { - showAIPanel, - showExportPanel, - showSelectionPanel, - showPythonToolsPanel, - showMediaExplorer, - showFxRack, - showMidiEvents, - panelPositions, - rightSidebarWidth, - mediaExplorerHeight, - selectedProviderId - }; - useEffect(() => { - const prefs = prefsRef.current; - localStorage.setItem('sonic_preferences', JSON.stringify(prefs)); - if (!currentUser || currentUser === 'cached') return; - const timer = setTimeout(async () => { - try { - await window.SonicAPI.savePreferences(prefs); - } catch (e) {} - }, 2000); - return () => clearTimeout(timer); - }, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, showMediaExplorer, showFxRack, showMidiEvents, panelPositions, rightSidebarWidth, mediaExplorerHeight, selectedProviderId, currentUser]); - return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { - className: "h-full w-full flex flex-col bg-[#1e1e1e]" - }, /*#__PURE__*/React.createElement("header", { - className: "h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none" - }, [{ - label: 'File', - items: [{ - label: 'New Project', - icon: 'file-plus', - shortcut: 'Ctrl+N', - action: () => { - setTracks([{ - id: '1', - name: 'Track 01', - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: '#0f766e', - markers: [], - serverFileId: null, - clips: [], - sections: [], - midiItems: [] - }, { - id: '2', - name: 'Track 02', - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: '#1d4ed8', - markers: [], - serverFileId: null, - clips: [], - sections: [], - midiItems: [] - }]); - setSelectedTrackId('1'); - setProjectName(''); - setCurrentProjectId(null); - localStorage.removeItem('sonic_project_name'); - localStorage.removeItem('sonic_project_id'); - showToast('New project created', 'info'); - } - }, { - label: 'Open Project...', - icon: 'folder-open', - shortcut: 'Ctrl+O', - action: () => handleImportSFS() - }, { - label: 'Save Project', - icon: 'upload-cloud', - shortcut: 'Ctrl+S', - action: () => handleSaveProject() - }, { - label: 'Save As...', - icon: 'download', - shortcut: 'Ctrl+Alt+S', - action: () => setSaveAsModalOpen(true) - }, { - sep: true - }, { - label: 'Config AI Providers...', - icon: 'settings', - action: () => setAiConfigModalOpen(true) - }, { - label: 'Import Audio...', - icon: 'file-input', - shortcut: 'Ctrl+Alt+I', - action: () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'audio/*'; - input.onchange = async e => { - if (e.target.files[0]) { - addNewTrack(); - const newId = (tracks.length + 1).toString(); - setTimeout(() => loadFileOnTrack(newId, e.target.files[0]), 100); - } - }; - input.click(); - showToast('Import audio', 'info'); - } - }, { - label: 'Export Mix...', - icon: 'file-output', - action: () => triggerWavExport() - }, { - sep: true - }, ...(currentUser ? [{ - label: 'Profile', - icon: 'user', - action: () => setProfileModalOpen(true) - }] : []), ...(currentUser && currentUser.role === 'admin' ? [{ - label: 'System Manager', - icon: 'settings', - action: () => setSystemManagerModalOpen(true) - }] : []), { - label: 'Logout', - icon: 'log-out', - action: () => handleLogout() - }] - }, { - label: 'Edit', - items: [{ - label: 'Insert New Track', - icon: 'plus', - shortcut: 'Ctrl+I', - action: addNewTrack - }, { - label: 'Insert Music to Track', - icon: 'music', - shortcut: 'Ctrl+Alt+I', - action: () => showToast('Select music file to insert', 'info') - }, { - sep: true - }, { - label: 'Edit in New Tab', - icon: 'file-edit', - shortcut: 'Ctrl+E', - action: () => openTempTab() - }, { - label: 'Split at Playhead', - icon: 'scissors', - shortcut: 'S', - action: () => handleSplitTrack(selectedTrackId) - }, { - label: 'Merge Tracks', - icon: 'combine', - shortcut: 'Ctrl+M', - action: () => { - handleMergeTracks(); - } - }, { - sep: true - }, { - label: 'Undo', - icon: 'undo', - shortcut: 'Ctrl+Z', - action: () => { - handleUndo(); - } - }, { - label: 'Redo', - icon: 'redo', - shortcut: 'Ctrl+Y', - action: () => { - handleRedo(); - } - }, { - sep: true - }, { - label: 'Copy', - icon: 'copy', - shortcut: 'Ctrl+C', - action: () => { - handleCopyTrack(); - } - }, { - label: 'Cut', - icon: 'scissors', - shortcut: 'Ctrl+X', - action: () => { - handleCutTrack(); - } - }, { - label: 'Paste', - icon: 'clipboard', - shortcut: 'Ctrl+V', - action: handlePasteTrack - }, { - sep: true - }, { - label: 'Delete Track', - icon: 'trash-2', - shortcut: 'Del', - action: () => { - handleDeleteTrack(); - } - }] - }, { - label: 'Insert', - items: [...(!sessionTabs.some(s => s.id === activeTab) ? [{ - label: 'Insert Section', - icon: 'folder-plus', - action: insertSectionAtPlayhead - }] : []), { - label: 'Insert MIDI item', - icon: 'music', - action: insertMidiItemAtPlayhead - }, { - label: 'Insert sound clip', - icon: 'file-input', - action: insertSoundClipAtCursor - }, { - label: 'Insert track', - icon: 'plus', - action: insertTrackBelow - }] - }, { - label: 'View', - items: [{ - label: 'Master Track', - icon: 'disc', - action: () => showToast('Master track view', 'info') - }, { - label: 'Maker View', - icon: 'layout', - action: () => showToast('Maker view', 'info') - }, { - label: 'Mixer', - icon: 'sliders', - action: () => showToast('Mixer panel', 'info') - }, { - label: 'Tempo Track', - icon: 'timer', - action: () => showToast('Tempo track', 'info') - }, { - label: 'Video', - icon: 'film', - action: () => showToast('Video panel', 'info') - }, { - label: 'Media Explorer', - icon: 'folder-search', - action: () => showToast('Media explorer', 'info') - }] - }, { - label: 'Tools', - items: [{ - label: 'Config AI Providers...', - icon: 'settings', - action: () => setAiConfigModalOpen(true) - }, { - label: 'DSP Tools Panel', - icon: 'wrench', - action: () => openPanel('python_tools') - }] - }, { - label: 'Help', - items: [{ - label: 'About SonicForge', - icon: 'info', - action: () => showToast('SonicForge Studio v1.0 - Professional DAW', 'info') - }] - }].map(menu => /*#__PURE__*/React.createElement("div", { - key: menu.label, - className: "relative" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label), - className: `px-3 py-1 text-xs font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}` - }, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", { - className: `absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label === 'Edit' ? 'w-72' : 'w-64'}`, - onClick: () => setMenuOpen(null) - }, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", { - key: i, - className: "h-px bg-zinc-700 my-1" - }) : /*#__PURE__*/React.createElement("button", { - key: item.label, - onClick: e => { - e.stopPropagation(); - item.action(); - setMenuOpen(null); - }, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": item.icon, - className: "w-3.5 h-3.5 text-zinc-500 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, item.label), item.shortcut && /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, item.shortcut)))))), /*#__PURE__*/React.createElement("div", { - className: "flex-1" - }), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-2 px-2" - }, /*#__PURE__*/React.createElement("span", { - className: `text-xs font-bold uppercase px-1.5 py-0.5 rounded ${serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' : serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' : 'bg-red-950 text-red-400'}` - }, "Server: ", serverStatus), /*#__PURE__*/React.createElement("button", { - onClick: () => setShowAIConfig(!showAIConfig), - className: `px-1.5 py-0.5 rounded text-xs border transition ${showAIConfig ? 'bg-purple-900 text-purple-200 border-purple-700' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'}` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "cpu", - className: "w-3 h-3" - }))))), menuOpen && /*#__PURE__*/React.createElement("div", { - className: "fixed inset-0 z-40", - onClick: () => setMenuOpen(null) - }), /*#__PURE__*/React.createElement("div", { - className: "h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => setActiveTab('main'), - className: `px-3 text-xs font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${activeTab === 'main' ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "layout-dashboard", - className: "w-3 h-3" - })), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", { - key: st.id, - className: "flex items-stretch" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => setActiveTab(st.id), - className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "layout-dashboard", - className: "w-3 h-3" - })), /*#__PURE__*/React.createElement("span", { - className: "max-w-[120px] truncate" - }, st.name)), /*#__PURE__*/React.createElement("button", { - onClick: () => closeSessionTab(st.id), - className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs", - title: "Close tab" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - }))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", { - key: st.id, - className: "flex items-stretch" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => setActiveTab(st.id), - className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-amber-400 border-amber-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "file-edit", - className: "w-3 h-3" - })), /*#__PURE__*/React.createElement("span", { - className: "max-w-[100px] truncate" - }, st.label)), /*#__PURE__*/React.createElement("button", { - onClick: () => closeSubTab(st.id), - className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs", - title: "Close tab" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })))))), showAIConfig && /*#__PURE__*/React.createElement("div", { - className: "bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all" - }, /*#__PURE__*/React.createElement("div", { - className: "text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "cpu", - className: "w-4 h-4" - })), " Cấu hình cổng kết nối API"), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-1 md:grid-cols-3 gap-2 text-xs" - }, /*#__PURE__*/React.createElement("div", { - className: "flex flex-col gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 font-bold uppercase" - }, "Endpoint Base URL"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: aiConfig.baseUrl, - onChange: e => setAiConfig(prev => ({ - ...prev, - baseUrl: e.target.value - })), - className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs", - placeholder: "https://api.openai.com/v1" - })), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 font-bold uppercase" - }, "API Token Key"), /*#__PURE__*/React.createElement("input", { - type: "password", - value: aiConfig.apiKey, - onChange: e => setAiConfig(prev => ({ - ...prev, - apiKey: e.target.value - })), - className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs", - placeholder: "sk-..." - })), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 font-bold uppercase" - }, "Model Name"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: aiConfig.model, - onChange: e => setAiConfig(prev => ({ - ...prev, - model: e.target.value - })), - className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs", - placeholder: "gpt-4o-mini" - })))), /*#__PURE__*/React.createElement("div", { - className: "h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 shadow-lg", - title: "Kéo để di chuyển toolbar", - style: { - cursor: 'grab' - } - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-0.5 mr-1 text-zinc-600" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3" - }))), /*#__PURE__*/React.createElement("button", { - onClick: () => { - setActiveTool('select'); - showToast('Select Tool', 'info'); - }, - className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'select' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, - title: "Select Tool (V)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "mouse-pointer", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: () => { - setActiveTool('grab'); - showToast('Grab Tool', 'info'); - }, - className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'grab' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, - title: "Grab Tool (H)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "hand", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => { - setActiveTool('razor'); - showToast('Razor Tool', 'info'); - }, - className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'razor' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, - title: "Razor Tool (C)" - }, /*#__PURE__*/React.createElement("svg", { - className: "w-3.5 h-3.5 text-orange-400", - viewBox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - strokeWidth: "2.5", - strokeLinecap: "round", - strokeLinejoin: "round" - }, /*#__PURE__*/React.createElement("path", { - d: "M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" - }), /*#__PURE__*/React.createElement("path", { - d: "M4 9h16l-3 9H7z" - }), /*#__PURE__*/React.createElement("circle", { - cx: "12", - cy: "6", - r: "1" - }))), /*#__PURE__*/React.createElement("button", { - onClick: handleGlueTracks, - className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800", - title: "Glue Clips" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "link", - className: "w-3.5 h-3.5" - })))), /*#__PURE__*/React.createElement("button", { - onClick: () => { - setActiveTool('pen'); - showToast('Pen Tool', 'info'); - }, - className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'pen' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, - title: "Pen Tool (P)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "pen-tool", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-5 bg-zinc-700 mx-0.5" - }), /*#__PURE__*/React.createElement("button", { - onClick: handleCutTrack, - className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800", - title: "Cut (Ctrl+X)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: handleCopyTrack, - className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800", - title: "Copy (Ctrl+C)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "copy", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: handlePasteTrack, - className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800", - title: "Paste (Ctrl+V)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "clipboard", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-5 bg-zinc-700 mx-0.5" - }), /*#__PURE__*/React.createElement("button", { - onClick: addNewTrack, - className: "px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition", - title: "Thêm Track Mới (Ctrl+I)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "plus", - className: "w-3.5 h-3.5" - })), /*#__PURE__*/React.createElement("span", null, "Track")), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-5 bg-zinc-700 mx-0.5" - }), /*#__PURE__*/React.createElement("button", { - onClick: handleUndo, - disabled: undoStack.length === 0, - className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30", - title: "Undo (Ctrl+Z)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "undo", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: handleRedo, - disabled: redoStack.length === 0, - className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30", - title: "Redo (Ctrl+Y)" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "redo", - className: "w-3.5 h-3.5" - })))), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-5 bg-zinc-800 mx-0.5" - }), /*#__PURE__*/React.createElement("button", { - onClick: () => { - if (activeTab !== 'main') { - setSubTabs(prev => prev.map(s => s.id === activeTab ? { - ...s, - currentTime: 0 - } : s)); - } else { - setCurrentTime(0); - } - }, - className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Quay lại đầu" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "skip-back", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: () => { - if (activeTab !== 'main') { - setSubTabs(prev => prev.map(s => { - if (s.id !== activeTab) return s; - const left = s.selectionStart !== null && s.selectionEnd !== null ? Math.min(s.selectionStart, s.selectionEnd) : null; - return left !== null ? { - ...s, - currentTime: left - } : s; - })); - } else { - if (selLeft !== null) setCurrentTime(selLeft); - } - }, - className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Đầu vùng chọn" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "step-back", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: handlePlayPause, - className: `w-7 h-7 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, - title: isPlaying ? "Tạm dừng" : "Play" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": isPlaying ? "pause" : "play", - className: "w-3.5 h-3.5 fill-current" - }))), /*#__PURE__*/React.createElement("button", { - onClick: handleStop, - className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Stop" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "square", - className: "w-3.5 h-3.5 fill-current" - }))), /*#__PURE__*/React.createElement("button", { - onClick: () => { - if (activeTab !== 'main') { - setSubTabs(prev => prev.map(s => { - if (s.id !== activeTab) return s; - const right = s.selectionStart !== null && s.selectionEnd !== null ? Math.max(s.selectionStart, s.selectionEnd) : null; - return right !== null ? { - ...s, - currentTime: right - } : s; - })); - } else { - if (selRight !== null) setCurrentTime(selRight); - } - }, - className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Cuối vùng chọn" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "step-forward", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: () => { - if (activeTab !== 'main') { - setSubTabs(prev => prev.map(s => { - if (s.id !== activeTab) return s; - const duration = s.buffer ? s.buffer.duration / (s.speed || 1.0) : 0; - return { - ...s, - currentTime: duration - }; - })); - } else { - setCurrentTime(maxDuration); - } - }, - className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", - title: "Đến cuối" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "skip-forward", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-5 bg-zinc-800 mx-0.5" - }), /*#__PURE__*/React.createElement("button", { - onClick: () => setIsLoopingSelection(prev => !prev), - className: `w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, - title: isLoopingSelection ? selLeft !== null && selRight !== null ? "Loop vùng chọn" : "Loop timeline" : "Bật loop" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "repeat", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] text-zinc-500 font-bold uppercase ml-2" - }, "Snap"), /*#__PURE__*/React.createElement("select", { - value: snapValue, - onChange: e => setSnapValue(e.target.value), - className: "bg-black text-white text-[14px] px-1.5 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer" - }, /*#__PURE__*/React.createElement("option", { - value: "free" - }, "Free"), /*#__PURE__*/React.createElement("option", { - value: "1" - }, "1"), /*#__PURE__*/React.createElement("option", { - value: "1/2" - }, "1/2"), /*#__PURE__*/React.createElement("option", { - value: "1/4" - }, "1/4"), /*#__PURE__*/React.createElement("option", { - value: "1/8" - }, "1/8"), /*#__PURE__*/React.createElement("option", { - value: "1/16" - }, "1/16"), /*#__PURE__*/React.createElement("option", { - value: "1/32" - }, "1/32")), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-6 bg-zinc-800 mx-1.5" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] text-zinc-500 font-bold" - }, "Bars:"), /*#__PURE__*/React.createElement("input", { - type: "number", - min: "1", - value: beginBar, - onChange: e => { - const b = parseInt(e.target.value) || 1; - setBeginBar(b); - const beatDuration = 60 / parseInt(bpm || 120); - const t = (b - 1) * beatDuration * 4; - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(t); - setSelectionEnd(t + beatDuration * 4); - }, - className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] text-zinc-500" - }, "-"), /*#__PURE__*/React.createElement("input", { - type: "number", - min: "1", - value: endBar, - onChange: e => { - const b = parseInt(e.target.value) || 1; - setEndBar(b); - const beatDuration = 60 / parseInt(bpm || 120); - const t = (b - 1) * beatDuration * 4; - setSelectionEnd(t + beatDuration * 4); - setNumberBar(b - beginBar + 1); - }, - className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] text-zinc-500" - }, "#"), /*#__PURE__*/React.createElement("input", { - type: "number", - min: "1", - value: numberBar, - readOnly: true, - className: "w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" - }), /*#__PURE__*/React.createElement("div", { - className: "w-[1px] h-6 bg-zinc-800 mx-1.5" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] text-zinc-500" - }, "Start:"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: selLeft !== null ? formatTime(selLeft) : '', - onChange: e => { - const parts = e.target.value.split(/[:.]/); - if (parts.length === 3) { - const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(secs); - } - }, - className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] text-zinc-500" - }, "End:"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: selRight !== null ? formatTime(selRight) : '', - onChange: e => { - const parts = e.target.value.split(/[:.]/); - if (parts.length === 3) { - const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); - setSelectionEnd(secs); - } - }, - className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] text-zinc-500" - }, "Len:"), /*#__PURE__*/React.createElement("input", { - type: "text", - value: selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '', - onChange: e => { - const parts = e.target.value.split(/[:.]/); - if (parts.length === 3 && selLeft !== null) { - const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); - setSelectionEnd(selLeft + secs); - } - }, - className: "w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" - }), /*#__PURE__*/React.createElement("div", { - className: "flex-1" - }), /*#__PURE__*/React.createElement("div", { - className: "text-xs font-bold font-mono text-zinc-100" - }, formatTime(currentTime))), (() => { - const dockPanels = { - top: [], - right: [], - bottom: [], - left: [] - }; - const addPanel = (id, pos, visible) => { - if (visible) dockPanels[pos].push(id); - }; - addPanel('export', panelPositions.export, showExportPanel); - addPanel('ai', panelPositions.ai, showAIPanel); - addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); - addPanel('selection', panelPositions.selection, showSelectionPanel); - addPanel('media_explorer', 'bottom', showMediaExplorer); - addPanel('fx_rack', panelPositions.fx_rack || 'bottom', showFxRack); - addPanel('midi_events', panelPositions.midi_events || 'bottom', showMidiEvents); - const closePanel = id => { - if (id === 'export') setShowExportPanel(false);else if (id === 'ai') setShowAIPanel(false);else if (id === 'python_tools') setShowPythonToolsPanel(false);else if (id === 'selection') setShowSelectionPanel(false);else if (id === 'media_explorer') setShowMediaExplorer(false);else if (id === 'fx_rack') setShowFxRack(false);else if (id === 'midi_events') setShowMidiEvents(false); - }; - const renderPanelContent = panelId => { - const h = id => e => { - startPanelDrag(id, e); - }; - if (panelId === 'export') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: e => startPanelDrag('export', e) - }, /*#__PURE__*/React.createElement("h3", { - className: "font-bold text-xs text-zinc-200 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3 text-zinc-500" - })), /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "save", - className: "w-3.5 h-3.5 text-cyan-400" - })), " Export"), /*#__PURE__*/React.createElement("button", { - onClick: () => closePanel('export'), - className: "text-zinc-600 hover:text-zinc-300" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })))), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-1" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Ngu\u1ed3n"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.source, - onChange: e => setExportSettings(p => ({ - ...p, - source: e.target.value - })), - className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "project" - }, "Project (Mix)"), /*#__PURE__*/React.createElement("option", { - value: "track_mix" - }, "Track Selection"), /*#__PURE__*/React.createElement("option", { - value: "active_clip" - }, "Active Clip"), /*#__PURE__*/React.createElement("option", { - value: "clip_selection" - }, "Clip Selection"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "\u0110\u1ecbnh d\u1ea1ng"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.format, - onChange: e => setExportSettings(p => ({ - ...p, - format: e.target.value, - sampleRate: e.target.value === 'wav' ? '44100' : e.target.value === 'mp3' ? '44100' : '44100', - bitDepth: e.target.value === 'wav' ? '16' : '16', - quality: '44khz' - })), - className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "wav" - }, "WAV"), /*#__PURE__*/React.createElement("option", { - value: "mp3" - }, "MP3"), /*#__PURE__*/React.createElement("option", { - value: "ogg" - }, "OGG")))), exportSettings.format === 'wav' ? /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-1" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "SR (Hz)"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.sampleRate, - onChange: e => setExportSettings(p => ({ - ...p, - sampleRate: e.target.value - })), - className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "22500" - }, "22500"), /*#__PURE__*/React.createElement("option", { - value: "44100" - }, "44100"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Bit"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.bitDepth, - onChange: e => setExportSettings(p => ({ - ...p, - bitDepth: e.target.value - })), - className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "8" - }, "8"), /*#__PURE__*/React.createElement("option", { - value: "16" - }, "16"), /*#__PURE__*/React.createElement("option", { - value: "24" - }, "24")))) : /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-1" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Ch\u1ea5t l\u01b0\u1ee3ng"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.quality, - onChange: e => setExportSettings(p => ({ - ...p, - quality: e.target.value - })), - className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "44khz" - }, "44kHz"), /*#__PURE__*/React.createElement("option", { - value: "lossless" - }, "Lossless"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-1" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Kênh"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.channels, - onChange: e => setExportSettings(p => ({ - ...p, - channels: e.target.value - })), - className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "mono" - }, "Mono"), /*#__PURE__*/React.createElement("option", { - value: "stereo" - }, "Stereo"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("button", { - onClick: triggerWavExport, - disabled: isExporting, - className: "w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "download-cloud", - className: "w-3 h-3" - })), isExporting ? '...' : 'Export')); - if (panelId === 'ai') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col gap-1.5 flex-1 min-h-0" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: e => startPanelDrag('ai', e) - }, /*#__PURE__*/React.createElement("h3", { - className: "font-bold text-xs text-zinc-200 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3 text-zinc-500" - })), /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "cpu", - className: "w-3.5 h-3.5 text-purple-400" - })), " AI Copilot"), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => { - setAiActionLog([]); - showToast('Đã xoá nhật ký AI.', 'info'); - }, - className: "text-zinc-600 hover:text-zinc-300", - title: "Clear log" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "trash-2", - className: "w-3 h-3" - }))), /*#__PURE__*/React.createElement("button", { - onClick: () => closePanel('ai'), - className: "text-zinc-600 hover:text-zinc-300" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - }))))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1.5 w-full min-w-0 pb-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "cpu", - className: "w-3 h-3 text-purple-400" - })), /*#__PURE__*/React.createElement("select", { - value: selectedProviderId, - onChange: e => setSelectedProviderId(e.target.value), - className: "flex-1 min-w-0 max-w-full bg-[#141414] text-zinc-200 border border-zinc-700 rounded px-1 py-0.5 text-xs font-mono focus:outline-none focus:border-purple-600 truncate" - }, aiProviders.length === 0 ? /*#__PURE__*/React.createElement("option", { - value: "" - }, "Chưa có provider") : aiProviders.map(p => /*#__PURE__*/React.createElement("option", { - key: p.id, - value: p.id - }, p.name, p.is_active ? '' : ' (inactive)')))), /*#__PURE__*/React.createElement("div", { - className: "border-t border-zinc-800 pt-1 mt-1 shrink-0" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => { - if (window.DAWCommandDispatcher && window.DAWCommandDispatcher.undo) { - const entry = window.DAWCommandDispatcher.undo(); - if (entry) { - setAiActionLog(prev => [...prev, { - type: 'undo', - text: `Undo: ${entry.name}`, - time: Date.now() - }]); - showToast(`Undo AI: ${entry.name}`, 'info'); - } - } else { - handleUndo(); - setAiActionLog(prev => [...prev, { - type: 'undo', - text: 'Undo (Ctrl+Z)', - time: Date.now() - }]); - } - }, - className: "w-full text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded py-0.5" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "rotate-ccw", - className: "w-3 h-3" - }), "Undo"))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 min-h-0 flex flex-col overflow-hidden mt-1" - }, /*#__PURE__*/React.createElement("div", { - className: "text-[10px] font-bold text-zinc-400 uppercase shrink-0 pb-0.5" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center gap-1" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "list", - className: "w-3 h-3" - }), " Action Log")), /*#__PURE__*/React.createElement("div", { - ref: actionLogContainerRef, - className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text" - }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 italic select-text" - }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { - key: i, - className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` - }, new Date(entry.time).toLocaleTimeString(), entry.text)))), /*#__PURE__*/React.createElement("div", { - className: "border-t border-zinc-800 pt-1.5 mt-1 shrink-0" - }, /*#__PURE__*/React.createElement("div", { - className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "message-square", - className: "w-3 h-3" - })), " Copilot Prompt"), /*#__PURE__*/React.createElement("textarea", { - value: aiPrompt, - onChange: e => setAiPrompt(e.target.value), - placeholder: "Nhập lệnh điều khiển DAW... (VD: Tạo track mới tên Beat, đặt BPM 128)", - className: "w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-none", - rows: 2, - onKeyDown: e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleAISend(); - } else if (e.key === 'ArrowUp' && promptHistRef.current.length > 0) { - e.preventDefault(); - const idx = promptHistIdx === -1 ? promptHistRef.current.length - 1 : Math.max(0, promptHistIdx - 1); - setPromptHistIdx(idx); - setAiPrompt(promptHistRef.current[idx]); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - if (promptHistIdx === -1) return; - const idx = promptHistIdx + 1; - if (idx >= promptHistRef.current.length) { - setPromptHistIdx(-1); - setAiPrompt(''); - } else { - setPromptHistIdx(idx); - setAiPrompt(promptHistRef.current[idx]); - } - } - } - })), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1 mt-1 shrink-0" - }, /*#__PURE__*/React.createElement("button", { - onClick: handleAISend, - disabled: aiProcessing, - className: "flex-1 py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-xs border border-purple-500 flex items-center justify-center gap-1" - }, aiProcessing ? 'Đang suy luận...' : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "send", - className: "w-3 h-3" - })), " Gửi")), /*#__PURE__*/React.createElement("button", { - onClick: () => { - setAiPrompt(''); - setAiActionLog([]); - }, - className: "px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700" - }, "Clear")), /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 shrink-0" - }, "Enter để gửi nhanh")); - if (panelId === 'python_tools') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: e => startPanelDrag('python_tools', e) - }, /*#__PURE__*/React.createElement("h3", { - className: "font-bold text-xs text-amber-300 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3 text-zinc-500" - })), /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "wrench", - className: "w-3.5 h-3.5 text-amber-400" - })), " DSP Tools"), /*#__PURE__*/React.createElement("button", { - onClick: () => closePanel('python_tools'), - className: "text-zinc-600 hover:text-zinc-300" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })))), /*#__PURE__*/React.createElement("div", { - className: "p-1.5 bg-[#141414] rounded border border-zinc-800 text-[10px] font-mono text-zinc-400 space-y-0.5 leading-relaxed" - }, dspSelectionStats ? [/*#__PURE__*/React.createElement("div", { - key: "track" - }, `Track: ${dspSelectionStats.trackName}`), /*#__PURE__*/React.createElement("div", { - key: "range" - }, `Range: ${dspSelectionStats.timeRange}`), /*#__PURE__*/React.createElement("div", { - key: "ch" - }, `Channels: ${dspSelectionStats.channels}`), /*#__PURE__*/React.createElement("div", { - key: "peak" - }, `Peak Vol: ${dspSelectionStats.peakVolume}`)] : "Chưa chọn track"), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-1 text-xs" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => runPythonTool('normalize'), - className: "py-1 bg-amber-950/80 hover:bg-amber-900 text-amber-300 border border-amber-800/80 rounded font-bold flex items-center justify-center gap-1" - }, "⚡ Peak Norm (0dB)"), /*#__PURE__*/React.createElement("button", { - onClick: () => runPythonTool('invert_phase'), - className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" - }, "🔄 Phase Invert"), /*#__PURE__*/React.createElement("button", { - onClick: () => runPythonTool('swap_channels'), - className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" - }, "🔀 Swap L/R"), /*#__PURE__*/React.createElement("button", { - onClick: () => runPythonTool('synth_wave'), - className: "py-1 bg-teal-950/80 hover:bg-teal-900 text-teal-300 border border-teal-800/80 rounded font-bold flex items-center justify-center gap-1" - }, "🎹 Gen Synth Tone"))); - if (panelId === 'selection') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: e => startPanelDrag('selection', e) - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500 font-bold uppercase flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3 text-zinc-500" - })), " Selection"), /*#__PURE__*/React.createElement("button", { - onClick: () => closePanel('selection'), - className: "text-zinc-600 hover:text-zinc-300" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })))), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Start"), /*#__PURE__*/React.createElement("input", { - type: "number", - step: "0.01", - value: selectionStats.start, - onChange: e => handleSelectionInputChange('start', e.target.value), - className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" - })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "End"), /*#__PURE__*/React.createElement("input", { - type: "number", - step: "0.01", - value: selectionStats.end, - onChange: e => handleSelectionInputChange('end', e.target.value), - className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" - })), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col justify-center" - }, /*#__PURE__*/React.createElement("span", { - className: "text-[7px] text-zinc-500 font-bold uppercase" - }, "Len"), /*#__PURE__*/React.createElement("span", { - className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5" - }, selectionStats.length, "s"))), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1" - }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Begin Bar"), /*#__PURE__*/React.createElement("input", { - type: "number", - min: "1", - value: beginBar, - onChange: e => { - const b = parseInt(e.target.value) || 1; - setBeginBar(b); - const beatDuration = 60 / parseInt(bpm || 120); - const t = (b - 1) * beatDuration * 4; - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(t); - setSelectionEnd(t + beatDuration * 4); - }, - className: "w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" - })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { - className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "End Bar"), /*#__PURE__*/React.createElement("input", { - type: "number", - min: "1", - value: endBar, - onChange: e => { - const b = parseInt(e.target.value) || 1; - setEndBar(b); - const beatDuration = 60 / parseInt(bpm || 120); - const t = (b - 1) * beatDuration * 4; - setSelectionEnd(t + beatDuration * 4); - setNumberBar(b - beginBar + 1); - }, - className: "w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" - })), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col justify-center" - }, /*#__PURE__*/React.createElement("span", { - className: "text-[7px] text-zinc-500 font-bold uppercase" - }, "# Bars"), /*#__PURE__*/React.createElement("span", { - className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5" - }, numberBar)))); - if (panelId === 'media_explorer') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: e => startPanelDrag('media_explorer', e) - }, /*#__PURE__*/React.createElement("h3", { - className: "font-bold text-xs text-emerald-300 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3 text-zinc-500" - })), /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "folder-open", - className: "w-3.5 h-3.5 text-emerald-400" - })), " Media Explorer"), /*#__PURE__*/React.createElement("button", { - onClick: () => closePanel('media_explorer'), - className: "text-zinc-600 hover:text-zinc-300" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 overflow-y-auto text-xs text-zinc-400 font-mono p-2" - }, "// Placeholder: Media files browser")); - if (panelId === 'fx_rack') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: e => startPanelDrag('fx_rack', e) - }, /*#__PURE__*/React.createElement("h3", { - className: "font-bold text-xs text-rose-300 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3 text-zinc-500" - })), /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sliders", - className: "w-3.5 h-3.5 text-rose-400" - })), " Plugin FX Rack"), /*#__PURE__*/React.createElement("button", { - onClick: () => closePanel('fx_rack'), - className: "text-zinc-600 hover:text-zinc-300" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 text-xs text-zinc-500 italic flex items-center justify-center" - }, "No FX plugins loaded")); - if (panelId === 'midi_events') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", - onMouseDown: e => startPanelDrag('midi_events', e) - }, /*#__PURE__*/React.createElement("h3", { - className: "font-bold text-xs text-sky-300 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "grip-vertical", - className: "w-3 h-3 text-zinc-500" - })), /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "music", - className: "w-3.5 h-3.5 text-sky-400" - })), " MIDI Event List"), /*#__PURE__*/React.createElement("button", { - onClick: () => closePanel('midi_events'), - className: "text-zinc-600 hover:text-zinc-300" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 text-xs text-zinc-500 italic flex items-center justify-center" - }, "No MIDI events selected")); - return null; - }; - const renderDock = (pos, title) => { - const panels = dockPanels[pos]; - if (panels.length === 0) return null; - const isSide = pos === 'left' || pos === 'right'; - const borderClass = pos === 'left' ? 'border-r' : pos === 'right' ? 'border-l' : pos === 'top' ? 'border-b' : 'border-t'; - const bgClass = 'bg-[#1e1e1e]'; - const highlight = panelDragRef.current && panelDropZone === pos; - if (pos === 'right') return /*#__PURE__*/React.createElement("div", { - id: "right-sidebar", - className: `${borderClass} ${bgClass} flex flex-col overflow-hidden select-none h-full`, - style: { - width: `${rightSidebarWidth}px`, - minWidth: '200px', - maxWidth: '600px', - flexShrink: 0 - } - }, /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex flex-col gap-2 p-2 overflow-hidden h-full" - }, panels.map((p, idx) => /*#__PURE__*/React.createElement(React.Fragment, { - key: p - }, /*#__PURE__*/React.createElement("div", { - className: 'flex flex-col flex-1 min-h-0 border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-3' - }, renderPanelContent(p)), idx < panels.length - 1 && /*#__PURE__*/React.createElement("div", { - className: "h-1.5 cursor-ns-resize hover:bg-cyan-500/50 transition-colors rounded shrink-0", - onMouseDown: startRowResize - }))))); - const sideClass = isSide ? 'shrink-0 overflow-y-auto' : 'shrink-0'; - return /*#__PURE__*/React.createElement("div", { - id: "daw-bottom", - className: `${sideClass} ${borderClass} ${bgClass} flex ${isSide ? 'flex-col p-2 gap-2' : 'flex-row p-1.5 gap-3'} select-none ${highlight ? 'ring-2 ring-cyan-500 ring-inset' : ''}` - }, panels.map(p => /*#__PURE__*/React.createElement("div", { - key: p, - className: `${isSide ? 'w-full' : 'flex-none w-[320px]'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2` - }, renderPanelContent(p)))); - }; - return /*#__PURE__*/React.createElement("div", { - ref: workspaceRef, - className: "flex-1 flex flex-col overflow-hidden select-none daw-bg relative" - }, panelDragRef.current && panelDropZone && /*#__PURE__*/React.createElement("div", { - className: "absolute inset-0 z-50 pointer-events-none" - }, panelDropZone === 'top' && /*#__PURE__*/React.createElement("div", { - className: "absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" - }), panelDropZone === 'bottom' && /*#__PURE__*/React.createElement("div", { - className: "absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" - }), panelDropZone === 'left' && /*#__PURE__*/React.createElement("div", { - className: "absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" - }), panelDropZone === 'right' && /*#__PURE__*/React.createElement("div", { - className: "absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" - })), dragGhostPanel && dragGhostPos && /*#__PURE__*/React.createElement("div", { - className: "fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56", - style: { - left: dragGhostPos.x, - top: dragGhostPos.y - } - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-2 text-xs text-zinc-200 font-bold" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "move", - className: "w-3.5 h-3.5 text-cyan-400" - })), dragGhostPanel === 'export' ? 'Export Panel' : dragGhostPanel === 'ai' ? 'AI Panel' : dragGhostPanel === 'python_tools' ? 'Audio Processing Panel' : 'Selection Panel'), /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-500 mt-1" - }, "Drop at edge to dock")), renderDock('top', 'Top'), /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex overflow-hidden" - }, renderDock('left', 'Left'), /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex flex-col overflow-hidden min-w-0" - }, /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex overflow-hidden" - }, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { - ref: tcpContainerRef, - onScroll: handleTCPScroll, - className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", - style: { - scrollbarWidth: 'none', - msOverflowStyle: 'none' - } - }, /*#__PURE__*/React.createElement("div", { - className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sliders", - className: "w-3.5 h-3.5 text-cyan-400" - })), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", { - onClick: addNewTrack, - className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "plus", - className: "w-3 h-3" - })), " Add Track")), /*#__PURE__*/React.createElement("div", { - className: "sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between w-full" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs font-bold text-purple-400 font-mono" - }, "TM"), /*#__PURE__*/React.createElement("span", { - className: "text-xs font-semibold text-zinc-300" - }, "Tempo")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("input", { - type: "number", - value: bpm, - onChange: e => setBpm(e.target.value), - onBlur: () => localStorage.setItem('studio_bpm', bpm), - className: "w-12 bg-zinc-800 border border-zinc-700 rounded text-xs text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500", - min: "40", - max: "300" - }), /*#__PURE__*/React.createElement("span", { - className: "text-xs text-zinc-500" - }, "BPM")))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]" - }, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", { - className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "plus-circle", - className: "w-8 h-8 text-cyan-400 opacity-80" - })), /*#__PURE__*/React.createElement("p", { - className: "text-xs font-medium" - }, "Chưa có Track nào trong dự án."), /*#__PURE__*/React.createElement("button", { - onClick: addNewTrack, - className: "px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "plus", - className: "w-3.5 h-3.5" - })), " Thêm Track Mới")) : activeTracks.map((track, idx) => { - const isSelected = selectedTrackId === track.id; - return /*#__PURE__*/React.createElement("div", { - key: track.id, - style: { - height: `${track.height || 96}px` - }, - className: `shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`, - onClick: () => setSelectedTrackId(track.id) - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-start justify-between" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs font-bold text-zinc-500 font-mono" - }, (idx + 1).toString().padStart(2, '0')), /*#__PURE__*/React.createElement("label", { - onClick: e => { - e.stopPropagation(); - const el = e.currentTarget.querySelector('input'); - if (el) el.click(); - }, - className: "cursor-pointer" - }, /*#__PURE__*/React.createElement("input", { - type: "color", - value: track.color || '#0f766e', - onChange: e => { - e.stopPropagation(); - updateTrackColor(track.id, e.target.value); - }, - className: "w-0 h-0 opacity-0 absolute pointer-events-none" - }), /*#__PURE__*/React.createElement("div", { - className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", - style: { - backgroundColor: track.color - } - })), editingTrackName === track.id ? /*#__PURE__*/React.createElement("input", { - type: "text", - value: editNameInput, - autoFocus: true, - onChange: e => setEditNameInput(e.target.value), - onBlur: () => { - updateTrackName(track.id, editNameInput || track.name); - setEditingTrackName(null); - }, - onKeyDown: e => { - if (e.key === 'Enter') { - updateTrackName(track.id, editNameInput || track.name); - setEditingTrackName(null); - } - if (e.key === 'Escape') setEditingTrackName(null); - }, - onClick: e => e.stopPropagation(), - className: "text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" - }) : /*#__PURE__*/React.createElement("span", { - className: "text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors", - title: "Click to rename", - onClick: e => { - e.stopPropagation(); - setEditingTrackName(track.id); - setEditNameInput(track.name); - } - }, track.name)), /*#__PURE__*/React.createElement("div", { - className: "flex flex-wrap gap-0.5 max-w-[100px] mb-0.5" - }, (track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ - id: 'default', - name: track.name, - startTime: track.startTime - }] : []).slice(0, 3).map(c => /*#__PURE__*/React.createElement("span", { - key: c.id, - className: "text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700", - title: c.name || track.name, - onClick: e => { - e.stopPropagation(); - setSelectedTrackId(track.id); - clearLocalSelection(); - setSelectionMode('global'); - const start = c.startTime || 0; - const end = start + (c.buffer ? c.buffer.duration : 2); - setSelectionStart(start); - setSelectionEnd(end); - showToast(`Selected: ${c.name || track.name}`, 'info'); - } - }, c.name || track.name), editingClipName && editingClipName.trackId === track.id && editingClipName.clipId === c.id ? /*#__PURE__*/React.createElement("input", { - type: "text", - value: editNameInput, - autoFocus: true, - onChange: e => setEditNameInput(e.target.value), - onBlur: () => { - if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); - setEditingClipName(null); - }, - onKeyDown: e => { - if (e.key === 'Enter') { - if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); - setEditingClipName(null); - } - if (e.key === 'Escape') setEditingClipName(null); - }, - onClick: e => e.stopPropagation(), - className: "w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none" - }) : /*#__PURE__*/React.createElement("button", { - className: "text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0", - title: "Sửa tên clip", - onClick: e => { - e.stopPropagation(); - setEditingClipName({ - trackId: track.id, - clipId: c.id - }); - setEditNameInput(c.name || track.name); - } - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "pencil", - className: "w-2.5 h-2.5" - })))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - toggleTrackMute(track.id); - }, - className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` - }, "M"), /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - toggleTrackSoloEvaluate(track.id); - }, - className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` - }, "S"), /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - deleteTrack(track.id); - }, - className: "p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "trash-2", - className: "w-3.5 h-3.5" - }))))), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col gap-0.5 text-xs", - onClick: e => e.stopPropagation() - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "w-8 text-right text-zinc-500 text-xs" - }, "Vol:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-50", - max: "7", - step: "0.5", - value: track.volumeDb ?? 0, - onChange: e => updateTrackVolumeDb(track.id, parseFloat(e.target.value)), - className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500", - style: { - height: '4px' - } - }), /*#__PURE__*/React.createElement("span", { - className: "w-12 text-right font-mono text-zinc-300 text-xs" - }, track.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "w-8 text-right text-zinc-500 text-xs" - }, "Pan:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-100", - max: "100", - step: "1", - value: track.pan ?? 0, - onChange: e => updateTrackPan(track.id, parseInt(e.target.value)), - className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500", - style: { - height: '4px' - } - }), /*#__PURE__*/React.createElement("span", { - className: "w-12 text-right font-mono text-zinc-300 text-xs" - }, track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'))), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1.5 mt-1", - onClick: e => e.stopPropagation() - }, /*#__PURE__*/React.createElement("input", { - type: "file", - id: `upload-${track.id}`, - accept: "audio/*", - className: "hidden", - onChange: e => loadFileOnTrack(track.id, e.target.files[0]) - }), /*#__PURE__*/React.createElement("label", { - htmlFor: `upload-${track.id}`, - className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "upload", - className: "w-3 h-3" - })), " File"), /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - showToast('FX panel for track ' + track.id, 'info'); - }, - className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-xs font-bold flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "wand-2", - className: "w-3 h-3" - })), " FX: ", /*#__PURE__*/React.createElement("span", { - className: "text-zinc-500 font-normal" - }, "None")), /*#__PURE__*/React.createElement("button", { - onClick: () => generateSynthToTrack(track.id, 'synth'), - className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-xs font-bold flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "music", - className: "w-3 h-3" - })), " Synth: ", /*#__PURE__*/React.createElement("span", { - className: "text-zinc-500 font-normal" - }, "None"))), /*#__PURE__*/React.createElement("div", { - onMouseDown: e => handleTrackResizeMouseDown(e, track.id), - className: "absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors", - onClick: e => e.stopPropagation() - })); - })), /*#__PURE__*/React.createElement("div", { - className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0" - })), /*#__PURE__*/React.createElement("div", { - ref: timelineWrapperRef, - onScroll: handleTimelineScroll, - className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" - }, /*#__PURE__*/React.createElement("div", { - style: { - width: `${timelineWidth}px` - }, - className: "relative flex flex-col min-h-full" - }, /*#__PURE__*/React.createElement("div", { - className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" - }, /*#__PURE__*/React.createElement("div", { - ref: rulerRef, - className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", - onMouseDown: handleRulerMouseDown - }, Array.from({ - length: Math.ceil(maxDuration) - }).map((_, i) => { - const sec = i; - const x = sec * zoom; - return /*#__PURE__*/React.createElement("div", { - key: i, - className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", - style: { - left: `${x}px` - } - }, formatTimeSimple(sec)); - }))), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { - className: "absolute inset-0 pointer-events-none z-20", - style: { - left: `${selLeft * zoom}px`, - width: `${(selRight - selLeft) * zoom}px`, - top: '40px' - } - }, /*#__PURE__*/React.createElement("div", { - className: "w-full h-full bg-amber-500/10", - style: { - borderLeft: '1px solid #f59e0b', - borderRight: '1px solid #f59e0b' - } - })), /*#__PURE__*/React.createElement("div", { - className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" - }, /*#__PURE__*/React.createElement("div", { - className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" - }, /*#__PURE__*/React.createElement(TempoTrackLane, { - bpm: parseInt(bpm) || 120, - zoom: zoom, - timelineWidth: timelineWidth, - viewportWidth: viewportWidth, - onPlayheadSet: handlePlayheadSet, - snapValue: snapValue, - onRulerMouseDown: handleRulerMouseDown, - scrollLeft: scrollLeft - }))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" - }, activeTracks.map((track, idx) => { - const isSelected = selectedTrackId === track.id; - return /*#__PURE__*/React.createElement("div", { - key: track.id, - style: { - height: `${track.height || 96}px` - }, - className: `shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, - onDragOver: e => e.preventDefault(), - onDrop: e => { - e.preventDefault(); - if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); - }, - onMouseEnter: () => setHoveredTrackId(track.id) - }, /*#__PURE__*/React.createElement(WaveformLane, { - track: track, - zoom: zoom, - timelineWidth: timelineWidth, - viewportWidth: viewportWidth, - onSelectRange: handleSelectRange, - onPlayheadSet: handlePlayheadSet, - isSelected: isSelected, - onSelectTrack: setSelectedTrackId, - markers: track.markers, - onTrackLaneMouseDown: handleTrackLaneMouseDown, - onContextMenu: handleContextMenu, - onClipDragStart: handleClipDragStart, - onClipStretchStart: handleClipStretchStart, - onSectionItemDragStart: handleSectionItemDragStart, - onSectionItemResizeStart: handleSectionItemResizeStart, - onSelectionEdgeDragStart: handleSelectionEdgeDragStart, - setSelectedClipId: setSelectedClipId, - selectedClipId: selectedClipId, - activeTool: activeTool, - onSplitTrackAtTime: handleSplitTrackAtTime, - onEditClipInSubTab: handleEditClipInSubTab, - onEditSectionInTab: handleEditSectionInTab, - snapValue: snapValue, - bpm: bpm, - selectionMode: selectionMode, - localSelectionTrackId: localSelectionTrackId, - localSelectionStart: localSelectionStart, - currentTime: currentTime, - getLocalAnchor: () => localSelectionAnchorRef.current, - onClearLocalSelection: clearLocalSelection, - onSetSelectionMode: setSelectionMode, - onSetSelectionStart: setSelectionStart, - onSetSelectionEnd: setSelectionEnd, - onSetCurrentTime: setCurrentTime, - onSetLocalSelectionTrackId: setLocalSelectionTrackId, - onSetLocalSelectionStart: setLocalSelectionStart, - onSetLocalSelectionEnd: setLocalSelectionEnd, - localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, - localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null, - scrollLeft: scrollLeft - }), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", { - className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none", - style: { - left: `${Math.min(localSelectionStart, localSelectionEnd) * zoom}px`, - width: `${Math.abs(localSelectionEnd - localSelectionStart) * zoom}px` - } - }, /*#__PURE__*/React.createElement("div", { - className: "absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", - onMouseDown: e => handleHandleDragStart(e, 'left') - }), /*#__PURE__*/React.createElement("div", { - className: "absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", - onMouseDown: e => handleHandleDragStart(e, 'right') - })), track.buffer && /*#__PURE__*/React.createElement("div", { - className: "absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => handleSplitTrack(track.id), - className: "px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-xs flex items-center gap-1 border border-zinc-700/50" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-2.5 h-2.5 text-cyan-400" - })), " Cắt")), /*#__PURE__*/React.createElement("div", { - onMouseDown: e => handleTrackResizeMouseDown(e, track.id), - className: "absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors", - onClick: e => e.stopPropagation() - })); - }), /*#__PURE__*/React.createElement("div", { - className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800", - onMouseEnter: () => { - if (draggedClipRef.current || draggedSectionItemRef.current) { - setHoveredTrackId(addNewTrack()); - } - }, - onClick: addNewTrack - }, /*#__PURE__*/React.createElement("span", { - className: "flex items-center gap-1 text-zinc-400" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "plus", - className: "w-3.5 h-3.5" - })), " Kéo clip xuống hoặc Click tạo Track")), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { - className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing", - style: { - left: `${selLeft * zoom}px`, - width: `${(selRight - selLeft) * zoom}px` - }, - onMouseDown: handleSelectionBodyDragStart - }, /*#__PURE__*/React.createElement("div", { - className: "absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", - onMouseDown: e => handleHandleDragStart(e, 'left') - }), /*#__PURE__*/React.createElement("div", { - className: "absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", - onMouseDown: e => handleHandleDragStart(e, 'right') - })), /*#__PURE__*/React.createElement("div", { - className: "absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none", - style: { - left: `${playheadLeftPos}px` - } - }, /*#__PURE__*/React.createElement("div", { - className: "w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0" - })))))) : (() => { - const st = subTabs.find(s => s.id === activeTab); - if (!st) return null; - const subTrack = tracks.find(t => t.id === st.trackId); - const vTrack = subTrack ? { - ...subTrack, - buffer: st.buffer, - isSubTab: true - } : null; - const subTabTimelineWidth = Math.max(zoom * (st.buffer ? st.buffer.duration : 0), viewportWidth); - return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { - className: "w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900", - style: { - scrollbarWidth: 'none', - msOverflowStyle: 'none' - } - }, /*#__PURE__*/React.createElement("div", { - className: "sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0" - }, /*#__PURE__*/React.createElement("span", { - className: "text-xs font-bold text-zinc-500 uppercase" - }, "Sub-Tab"), /*#__PURE__*/React.createElement("button", { - onClick: () => closeSubTab(st.id), - className: "px-1.5 py-0.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "x", - className: "w-3 h-3" - })), " Close")), vTrack ? /*#__PURE__*/React.createElement("div", { - key: vTrack.id, - className: "flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between mb-2" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-2" - }, /*#__PURE__*/React.createElement("label", { - onClick: e => { - e.stopPropagation(); - const el = e.currentTarget.querySelector('input'); - if (el) el.click(); - }, - className: "cursor-pointer" - }, /*#__PURE__*/React.createElement("input", { - type: "color", - value: vTrack.color || '#0f766e', - onChange: e => { - e.stopPropagation(); - updateTrackColor(vTrack.id, e.target.value); - }, - className: "w-0 h-0 opacity-0 absolute pointer-events-none" - }), /*#__PURE__*/React.createElement("div", { - className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", - style: { - backgroundColor: vTrack.color - } - })), editingTrackName === vTrack.id ? /*#__PURE__*/React.createElement("input", { - type: "text", - value: editNameInput, - autoFocus: true, - onChange: e => setEditNameInput(e.target.value), - onBlur: () => { - updateTrackName(vTrack.id, editNameInput || vTrack.name); - setEditingTrackName(null); - }, - onKeyDown: e => { - if (e.key === 'Enter') { - updateTrackName(vTrack.id, editNameInput || vTrack.name); - setEditingTrackName(null); - } - if (e.key === 'Escape') setEditingTrackName(null); - }, - onClick: e => e.stopPropagation(), - className: "text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" - }) : /*#__PURE__*/React.createElement("span", { - className: "text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors", - title: "Click to rename", - onClick: e => { - e.stopPropagation(); - setEditingTrackName(vTrack.id); - setEditNameInput(vTrack.name); - } - }, vTrack.name)), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - toggleTrackMute(vTrack.id); - }, - className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` - }, "M"), /*#__PURE__*/React.createElement("button", { - onClick: e => { - e.stopPropagation(); - toggleTrackSoloEvaluate(vTrack.id); - }, - className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${soloedTrackId === vTrack.id || vTrack.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` - }, "S"))), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col gap-2.5 text-[14px] mb-3" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "w-10 text-right text-zinc-500 text-[14px]" - }, "Vol:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-50", - max: "7", - step: "0.5", - id: `tcp-vol-${st.id}`, - value: vTrack.volumeDb ?? 0, - onChange: e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value)), - className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500", - style: { - height: '4px' - } - }), /*#__PURE__*/React.createElement("span", { - className: "w-16 text-right font-mono text-zinc-300 text-[14px]", - id: `tcp-vol-label-${st.id}` - }, vTrack.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "w-10 text-right text-zinc-500 text-[14px]" - }, "Pan:"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-100", - max: "100", - step: "1", - id: `tcp-pan-${st.id}`, - value: vTrack.pan ?? 0, - onChange: e => updateTrackPan(vTrack.id, parseInt(e.target.value)), - className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500", - style: { - height: '4px' - } - }), /*#__PURE__*/React.createElement("span", { - className: "w-16 text-right font-mono text-zinc-300 text-[14px]", - id: `tcp-pan-label-detailed-${st.id}` - }, vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C')), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1.5 text-[14px]" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { - ...s, - isLooping: !s.isLooping - } : s)), - className: `px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "repeat", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("button", { - onClick: () => updateSubTabEffects(st.id, { - reverse: !(st.effects || {}).reverse - }), - className: `px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects || {}).reverse ? 'bg-zinc-600 text-white border-zinc-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`, - title: "Reverse" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "arrow-left-right", - className: "w-3.5 h-3.5" - }))), /*#__PURE__*/React.createElement("span", { - className: "w-10 text-right text-zinc-500 text-[14px]" - }, "Loop:"), /*#__PURE__*/React.createElement("input", { - type: "number", - min: "0", - max: "999", - value: st.loopCount || 0, - onChange: e => setSubTabs(prev => prev.map(s => s.id === st.id ? { - ...s, - loopCount: Math.max(0, parseInt(e.target.value) || 0) - } : s)), - className: "w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono", - title: "Loop count" - }))), /*#__PURE__*/React.createElement("div", { - className: "flex gap-1 justify-between my-2.5" - }, /*#__PURE__*/React.createElement("div", { - className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", - title: "Normalize" - }, "Norm"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-12", - max: "0", - step: "0.1", - value: subTabNormVal, - onChange: e => setSubTabNormVal(parseFloat(e.target.value)), - style: { - writingMode: 'vertical-lr', - direction: 'rtl', - height: '120px', - width: '20px', - accentColor: '#a1a1aa' - }, - className: "my-2 cursor-pointer" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] font-mono text-zinc-300 font-bold" - }, subTabNormVal, "dB"), /*#__PURE__*/React.createElement("button", { - onClick: () => applySubTabEffect(st.id, 'normalize', subTabNormVal), - className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" - }, "Apply")), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", - title: "Pitch Shift" - }, "Pitch"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "-12", - max: "12", - step: "0.5", - value: subTabPitchVal, - onChange: e => setSubTabPitchVal(parseFloat(e.target.value)), - style: { - writingMode: 'vertical-lr', - direction: 'rtl', - height: '120px', - width: '20px', - accentColor: '#a1a1aa' - }, - className: "my-2 cursor-pointer" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] font-mono text-zinc-300 font-bold" - }, subTabPitchVal > 0 ? '+' : '', subTabPitchVal, "st"), /*#__PURE__*/React.createElement("button", { - onClick: () => applySubTabEffect(st.id, 'pitch', subTabPitchVal), - className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" - }, "Apply")), /*#__PURE__*/React.createElement("div", { - className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" - }, /*#__PURE__*/React.createElement("span", { - className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", - title: "Gain Multiplier" - }, "Gain"), /*#__PURE__*/React.createElement("input", { - type: "range", - min: "0", - max: "150", - step: "1", - value: subTabGainVal, - onChange: e => setSubTabGainVal(parseInt(e.target.value)), - style: { - writingMode: 'vertical-lr', - direction: 'rtl', - height: '120px', - width: '20px', - accentColor: '#a1a1aa' - }, - className: "my-2 cursor-pointer" - }), /*#__PURE__*/React.createElement("span", { - className: "text-[14px] font-mono text-zinc-300 font-bold" - }, subTabGainVal, "%"), /*#__PURE__*/React.createElement("button", { - onClick: () => applySubTabEffect(st.id, 'gain', subTabGainVal), - className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" - }, "Apply"))), /*#__PURE__*/React.createElement("div", { - className: "flex-1" - }), /*#__PURE__*/React.createElement("div", { - className: "mt-auto pt-2.5 border-t border-zinc-800" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase" - }, /*#__PURE__*/React.createElement("span", null, "Duration:"), /*#__PURE__*/React.createElement("span", { - className: "font-mono text-zinc-300" - }, st.buffer ? formatTime(st.buffer.duration) : '0s')), /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase" - }, /*#__PURE__*/React.createElement("span", null, "SR:"), /*#__PURE__*/React.createElement("span", { - className: "font-mono text-zinc-300" - }, st.buffer ? st.buffer.sampleRate : 0, " Hz")), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-2 gap-1 mb-2" - }, /*#__PURE__*/React.createElement("button", { - onClick: async e => { - subTabAiTrackIdRef.current = st.trackId; - handleAIScan(); - }, - disabled: analysisState.isRunning, - className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "map-pin", - className: "w-3 h-3" - })), " Scan"), /*#__PURE__*/React.createElement("button", { - onClick: async e => { - subTabAiTrackIdRef.current = st.trackId; - handleAICutToNewTrack(); - }, - disabled: analysisState.isRunning, - className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3 h-3" - })), " Cut"), /*#__PURE__*/React.createElement("button", { - onClick: async e => { - subTabAiTrackIdRef.current = st.trackId; - if (st.buffer) { - setSelectionRangeOnBuffer(st.buffer, st.selectionStart || 0, st.selectionEnd || st.buffer.duration); - } - handleAIAnalysicLoop(); - }, - disabled: analysisState.isRunning, - className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1 col-span-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sparkles", - className: "w-3 h-3" - })), " AI Analysic Loop")), /*#__PURE__*/React.createElement("button", { - onClick: () => exportSubTabBuffer(st.id), - className: "w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "download", - className: "w-4 h-4" - })), " Export"), /*#__PURE__*/React.createElement("button", { - onClick: () => applySubTab(st.id), - className: "w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "save", - className: "w-4 h-4" - })), " Save"))) : /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex items-center justify-center text-xs text-zinc-500" - }, "Track not found")), /*#__PURE__*/React.createElement("div", { - ref: timelineWrapperRef, - className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" - }, /*#__PURE__*/React.createElement("div", { - style: { - width: `${subTabTimelineWidth}px` - }, - className: "relative flex flex-col min-h-full" - }, /*#__PURE__*/React.createElement("div", { - className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" - }, /*#__PURE__*/React.createElement("div", { - className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", - onMouseDown: handleRulerMouseDown - }, Array.from({ - length: Math.ceil(st.buffer ? st.buffer.duration : 0) - }).map((_, i) => { - const sec = i; - const x = sec * zoom; - return /*#__PURE__*/React.createElement("div", { - key: i, - className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", - style: { - left: `${x}px` - } - }, formatTimeSimple(sec)); - }))), /*#__PURE__*/React.createElement("div", { - className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" - }, /*#__PURE__*/React.createElement("div", { - className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" - }, /*#__PURE__*/React.createElement(TempoTrackLane, { - bpm: parseInt(bpm) || 120, - zoom: zoom, - timelineWidth: subTabTimelineWidth, - onPlayheadSet: setCurrentTime, - snapValue: snapValue - }))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 flex flex-col relative bg-[#111111] min-h-full" - }, vTrack && /*#__PURE__*/React.createElement("div", { - style: { - height: `${subTabHeight}px` - }, - className: "relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0" - }, /*#__PURE__*/React.createElement(SubTabWaveform, { - buffer: st.buffer, - subTabId: st.id, - activeTab: activeTab, - activeTool: activeTool, - currentTime: st.currentTime, - selectionStart: st.selectionStart, - selectionEnd: st.selectionEnd, - onSelectRange: (start, end) => setSubTabs(prev => prev.map(s => s.id === st.id ? { - ...s, - selectionStart: start, - selectionEnd: end - } : s)), - onPlayheadSet: time => setSubTabs(prev => prev.map(s => s.id === st.id ? { - ...s, - currentTime: time - } : s)), - onContextMenu: (e, clickTime) => setContextMenu({ - x: e.clientX, - y: e.clientY, - isSubTab: true, - subTabId: st.id, - time: clickTime - }), - zoom: zoom, - timelineWidth: subTabTimelineWidth, - color: vTrack.color, - name: vTrack.name, - speed: st.speed || 1.0, - volumeNodes: st.volumeNodes || [], - panningNodes: st.panningNodes || [], - fadeInLen: st.fadeInLen || 0, - fadeOutLen: st.fadeOutLen || 0, - graphMode: st.graphMode, - channelInfo: st.channelInfo, - selectedNodeTime: subTabSelectedNodeTime, - setSelectedNodeTime: setSubTabSelectedNodeTime, - onUpdateNodes: nodes => setSubTabs(prev => prev.map(s => s.id === st.id ? { - ...s, - [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes - } : s)), - onUpdateFade: fade => setSubTabs(prev => prev.map(s => s.id === st.id ? { - ...s, - fadeInLen: fade.fadeInLen ?? s.fadeInLen, - fadeOutLen: fade.fadeOutLen ?? s.fadeOutLen - } : s)), - onModeToggle: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { - ...s, - graphMode: s.graphMode === 'pan' ? null : 'pan' - } : s)), - onSpeedChange: newSpeed => { - setSubTabs(prev => prev.map(s => { - if (s.id !== st.id) return s; - const oldSpeed = s.speed || 1.0; - const ratio = oldSpeed / newSpeed; - const newVolumeNodes = (s.volumeNodes || []).map(n => ({ - ...n, - time: n.time * ratio - })); - const newPanningNodes = (s.panningNodes || []).map(n => ({ - ...n, - time: n.time * ratio - })); - return { - ...s, - speed: newSpeed, - volumeNodes: newVolumeNodes, - panningNodes: newPanningNodes, - fadeInLen: (s.fadeInLen || 0) * ratio, - fadeOutLen: (s.fadeOutLen || 0) * ratio, - currentTime: (s.currentTime || 0) * ratio, - label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` - }; - })); - const n = activeTrackNodesRef.current[st.trackId]; - if (n && n.source) n.source.playbackRate.value = newSpeed; - // Reset time refs to prevent playhead jump when speed changes mid-playback - const ctx = getAudioContext(); - const elapsed = ctx.currentTime - startAudioTimeRef.current; - const oldSpeed = activePlaybackSpeedRef.current; - const ratio = oldSpeed / newSpeed; - startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; - startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; - startAudioTimeRef.current = ctx.currentTime; - activePlaybackSpeedRef.current = newSpeed; - } - }), /*#__PURE__*/React.createElement("div", { - onMouseDown: handleSubTabResizeMouseDown, - className: "absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" - })), st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && /*#__PURE__*/React.createElement("div", { - className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none", - style: { - left: `${Math.min(st.selectionStart, st.selectionEnd) * zoom}px`, - width: `${Math.abs(st.selectionEnd - st.selectionStart) * zoom}px` - } - }))))); - })())), /*#__PURE__*/React.createElement("div", { - className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10", - onMouseDown: startColResize - }), renderDock('right', 'Right')), renderDock('bottom', 'Bottom')); - })(), /*#__PURE__*/React.createElement("div", { - className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0" - }, /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-4" - }, /*#__PURE__*/React.createElement("span", null, "Status: ", isPlaying ? 'Playing' : 'Stopped'), activeTab !== 'main' && /*#__PURE__*/React.createElement("span", { - className: "text-amber-400 font-semibold uppercase" - }, "Sub-Tab"), activeTab === 'main' && /*#__PURE__*/React.createElement("span", { - className: "text-cyan-400 font-semibold uppercase" - }, "Track: ID ", selectedTrackId), selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { - className: "text-amber-400 font-semibold uppercase text-xs" - }, "Local Sel"), selectionMode === 'global' && /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 font-semibold uppercase text-xs" - }, "Global Sel"), soloedTrackId && /*#__PURE__*/React.createElement("span", { - className: "text-amber-500 font-semibold" - }, "Solo: ID ", soloedTrackId), isLoopingSelection && selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { - className: "text-emerald-400 font-semibold uppercase text-xs" - }, "Solo Loop"), isLoopingSelection && selectionMode !== 'local' && /*#__PURE__*/React.createElement("span", { - className: "text-cyan-400 font-semibold uppercase text-xs" - }, "Master Loop")), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-2" - }, /*#__PURE__*/React.createElement("button", { - onClick: () => setShowExportPanel(p => !p), - className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showExportPanel ? 'bg-cyan-900 text-cyan-300' : 'text-zinc-500 hover:text-zinc-300'}`, - title: `Export Panel (${panelPositions.export})` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "save", - className: "w-3 h-3" - })), /*#__PURE__*/React.createElement("span", { - className: "text-[7px] opacity-60" - }, showExportPanel ? panelPositions.export[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { - onClick: () => setShowSelectionPanel(p => !p), - className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showSelectionPanel ? 'bg-amber-900 text-amber-300' : 'text-zinc-500 hover:text-zinc-300'}`, - title: `Selection Panel (${panelPositions.selection})` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sliders", - className: "w-3 h-3" - })), /*#__PURE__*/React.createElement("span", { - className: "text-[7px] opacity-60" - }, showSelectionPanel ? panelPositions.selection[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { - onClick: () => setShowAIPanel(p => !p), - className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showAIPanel ? 'bg-purple-900 text-purple-300' : 'text-zinc-500 hover:text-zinc-300'}`, - title: `AI Panel (${panelPositions.ai})` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "cpu", - className: "w-3 h-3" - })), /*#__PURE__*/React.createElement("span", { - className: "text-[7px] opacity-60" - }, showAIPanel ? panelPositions.ai[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { - onClick: () => setShowFxRack(p => !p), - className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showFxRack ? 'bg-rose-900 text-rose-300' : 'text-zinc-500 hover:text-zinc-300'}`, - title: `FX Rack Panel (${panelPositions.fx_rack || 'bottom'})` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sliders", - className: "w-3 h-3" - })), /*#__PURE__*/React.createElement("span", { - className: "text-[7px] opacity-60" - }, showFxRack ? (panelPositions.fx_rack || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { - onClick: () => setShowMidiEvents(p => !p), - className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMidiEvents ? 'bg-sky-900 text-sky-300' : 'text-zinc-500 hover:text-zinc-300'}`, - title: `MIDI Events Panel (${panelPositions.midi_events || 'bottom'})` - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "music", - className: "w-3 h-3" - })), /*#__PURE__*/React.createElement("span", { - className: "text-[7px] opacity-60" - }, showMidiEvents ? (panelPositions.midi_events || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("span", { - className: "w-[1px] h-3 bg-zinc-800 mx-1" - }), /*#__PURE__*/React.createElement("span", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "info", - className: "w-3 h-3 text-zinc-600" - })), " Scroll: Zoom"), /*#__PURE__*/React.createElement("span", null, "|"), /*#__PURE__*/React.createElement("span", { - className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "keyboard", - className: "w-3 h-3 text-zinc-600" - })), " Ctrl+Scroll: Playhead"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", { - className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", - style: { - left: contextMenu.x, - top: contextMenu.y - }, - onClick: e => e.stopPropagation() - }, /*#__PURE__*/React.createElement("div", { - className: "px-3 py-1 text-xs text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1" - }, "Selection: ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0), " - ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)), /*#__PURE__*/React.createElement("div", { - className: "h-px bg-zinc-700 my-1" - }), /*#__PURE__*/React.createElement("button", { - onClick: () => { - handleSubTabCut(contextMenu.subTabId); - closeContextMenu(); - }, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3.5 h-3.5 text-rose-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Cut"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto" - }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { - onClick: () => { - handleSubTabCopy(contextMenu.subTabId); - closeContextMenu(); - }, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "copy", - className: "w-3.5 h-3.5 text-zinc-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Copy"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto" - }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { - onClick: () => { - handleSubTabPaste(contextMenu.subTabId); - closeContextMenu(); - }, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "clipboard", - className: "w-3.5 h-3.5 text-emerald-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Paste"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto" - }, "Ctrl+V")), /*#__PURE__*/React.createElement("button", { - onClick: () => { - handleSubTabDelete(contextMenu.subTabId); - closeContextMenu(); - }, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "trash-2", - className: "w-3.5 h-3.5 text-red-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Delete Selected Segment"), /*#__PURE__*/React.createElement("span", { - className: "text-amber-400 text-xs font-semibold font-mono ml-auto" - }, "Del")), /*#__PURE__*/React.createElement("div", { - className: "h-px bg-zinc-700 my-1" - }), /*#__PURE__*/React.createElement("button", { - onClick: () => { - handleSubTabLoop(contextMenu.subTabId, 4); - closeContextMenu(); - }, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "repeat", - className: "w-3.5 h-3.5 text-cyan-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Loop Selection 4 times"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto" - }, "Ctrl+L"))) : /*#__PURE__*/React.createElement("div", { - className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", - style: { - left: contextMenu.x, - top: contextMenu.y - }, - onClick: e => e.stopPropagation() - }, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", { - onClick: contextMenuEditSection, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "file-edit", - className: "w-3.5 h-3.5 text-amber-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Edit Section"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", { - onClick: contextMenuEdit, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "file-edit", - className: "w-3.5 h-3.5 text-amber-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Edit"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "Ctrl+E")), /*#__PURE__*/React.createElement("button", { - onClick: contextMenuSplit, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3.5 h-3.5 text-cyan-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Split"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "S")), /*#__PURE__*/React.createElement("button", { - onClick: contextMenuMerge, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "combine", - className: "w-3.5 h-3.5 text-purple-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Merge"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "Ctrl+M")), /*#__PURE__*/React.createElement("div", { - className: "h-px bg-zinc-700 my-1" - }), /*#__PURE__*/React.createElement("button", { - onClick: contextMenuCopy, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "copy", - className: "w-3.5 h-3.5 text-zinc-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Copy"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { - onClick: contextMenuCut, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3.5 h-3.5 text-rose-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Cut"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { - onClick: contextMenuPaste, - className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "clipboard", - className: "w-3.5 h-3.5 text-emerald-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Paste"), /*#__PURE__*/React.createElement("span", { - className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "Ctrl+V")), /*#__PURE__*/React.createElement("div", { - className: "h-px bg-zinc-700 my-1" - }), /*#__PURE__*/React.createElement("button", { - onClick: contextMenuDelete, - className: "w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "trash-2", - className: "w-3.5 h-3.5 text-red-400 shrink-0" - })), /*#__PURE__*/React.createElement("span", { - className: "flex-1" - }, "Delete"), /*#__PURE__*/React.createElement("span", { - className: "text-amber-400 text-xs font-semibold font-mono ml-auto pl-8" - }, "Del")))), toastMessage && /*#__PURE__*/React.createElement("div", { - className: "absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "bell", - className: `w-4 h-4 ${toastMessage.type === 'success' ? 'text-emerald-400' : toastMessage.type === 'error' ? 'text-rose-400' : toastMessage.type === 'warning' ? 'text-amber-400' : 'text-cyan-400'}` - })), toastMessage.text, toastMessage.onActionClick && /*#__PURE__*/React.createElement("button", { - onClick: () => { - toastMessage.onActionClick(); - setToastMessage(null); - }, - className: "ml-2 px-2 py-0.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-[10px] uppercase font-bold" - }, toastMessage.actionText || 'Tải về'))), /*#__PURE__*/React.createElement(AuthModal, { - isOpen: authModalOpen, - mode: authMode, - forceMandatory: isMandatoryLogin, - onClose: () => setAuthModalOpen(false), - onSuccess: handleAuthSuccess - }), /*#__PURE__*/React.createElement(ProfileModal, { - isOpen: profileModalOpen, - onClose: () => setProfileModalOpen(false), - tracks: tracks, - setTracks: setTracks, - setSelectedTrackId: setSelectedTrackId, - projectName: projectName, - setProjectName: setProjectName, - currentProjectId: currentProjectId, - setCurrentProjectId: setCurrentProjectId, - showToast: showToast - }), /*#__PURE__*/React.createElement(SaveProjectModal, { - isOpen: saveProjectModalOpen, - onClose: () => setSaveProjectModalOpen(false), - onSave: newName => { - handleSaveProjectWithName(newName); - } - }), /*#__PURE__*/React.createElement(SaveAsModal, { - isOpen: saveAsModalOpen, - onClose: () => setSaveAsModalOpen(false), - projectName: projectName, - onSaveCloud: newName => { - handleSaveAsCloud(newName); - }, - onSaveLocal: newName => { - handleExportSFS(newName); - setProjectName(newName); - localStorage.setItem('sonic_project_name', newName); - } - }), /*#__PURE__*/React.createElement(AIConfigModal, { - isOpen: aiConfigModalOpen, - onClose: () => setAiConfigModalOpen(false), - onConfigSaved: providers => { - setAiProviders(providers); - const active = providers.find(p => p.is_active) || providers[0]; - if (active) setSelectedProviderId(active.id); - } - }), /*#__PURE__*/React.createElement(SystemManagerModal, { - isOpen: systemManagerModalOpen, - onClose: () => setSystemManagerModalOpen(false) - })); -}; -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render(/*#__PURE__*/React.createElement(App, null)); -setTimeout(() => lucide.createIcons(), 300); +const{useState,useRef,useEffect,useMemo,useCallback}=React;// ── FastAPI Backend Configuration ── +const API_BASE_URL=window.location.origin;const API_AUDIO=`${API_BASE_URL}/api/v1/audio`;const API_MULTITRACK=`${API_BASE_URL}/api/v1/multitrack`;const API_TASKS=`${API_BASE_URL}/api/v1/audio/tasks`;// Handle ?sfs= from double-clicking a .sfs file (opens domain -> loads project) +(function handleSfsDeepLink(){try{const params=new URLSearchParams(window.location.search);const sfsParam=params.get('sfs');if(!sfsParam)return;const decoded=JSON.parse(decodeURIComponent(sfsParam));window.__pendingSfsProject=decoded;// consumed after auth in App +if(window.history.replaceState){window.history.replaceState({},document.title,window.location.pathname);}}catch(e){window.__pendingSfsProject=null;}})();// Storage for server-side file IDs mapped to track IDs +let serverFileIdMap={};let audioCtx;function getAudioContext(){if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)();if(window.SonicAudio&&window.SonicAudio.initAudioWorklet){window.SonicAudio.initAudioWorklet();}}if(audioCtx.state==='suspended'){audioCtx.resume();}return audioCtx;}const formatTime=secs=>{if(isNaN(secs)||secs<0)return"0:00.000";const m=Math.floor(secs/60);const s=Math.floor(secs%60);const ms=Math.floor(secs%1*1000).toString().padStart(3,'0');return`${m}:${s.toString().padStart(2,'0')}.${ms}`;};const formatTimeSimple=secs=>{if(isNaN(secs)||secs<0)return"0.00s";return`${secs.toFixed(2)}s`;};const formatBeat=(secs,bpmVal)=>{if(isNaN(secs)||secs<0)return"1.1.1";const beatDuration=60/bpmVal;const barDuration=beatDuration*4;const bar=Math.floor(secs/barDuration)+1;const beat=Math.floor(secs%barDuration/beatDuration)+1;const sub=Math.floor(secs%beatDuration/(beatDuration/4))+1;return`${bar}.${beat}.${sub}`;};const getBeatMarkers=(maxDur,bpmVal)=>{const beatDuration=60/bpmVal;const barDuration=beatDuration*4;const markers=[];for(let t=0;t<=maxDur;t+=beatDuration){const isBar=Math.abs(t%barDuration)<0.001||Math.abs(t%barDuration-barDuration)<0.001;markers.push({time:t,isBar,beatNum:Math.floor(t/beatDuration)+1});}return markers;};const findZeroCrossing=(buffer,targetTime)=>{if(!buffer)return targetTime;const sampleRate=buffer.sampleRate;const data=buffer.getChannelData(0);const targetSample=Math.floor(targetTime*sampleRate);const windowSize=Math.floor(0.04*sampleRate);const start=Math.max(0,targetSample-windowSize);const end=Math.min(data.length-2,targetSample+windowSize);let bestSample=targetSample;let minDistance=Infinity;for(let i=start;i<=end;i++){if(data[i]>=0&&data[i+1]<=0||data[i]<=0&&data[i+1]>=0){const dist=Math.abs(i-targetSample);if(dist{const[isDragging,setIsDragging]=useState(false);const startY=useRef(0);const startValue=useRef(0);const rotation=useMemo(()=>{const percent=(value-min)/(max-min);return-135+percent*270;},[value,min,max]);const handleMouseDown=e=>{setIsDragging(true);startY.current=e.clientY;startValue.current=value;document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleMouseMove=e=>{const deltaY=startY.current-e.clientY;const sensitivity=0.005;const newValue=Math.max(min,Math.min(max,startValue.current+deltaY*sensitivity));onChange(parseFloat(newValue.toFixed(2)));};const handleMouseUp=()=>{setIsDragging(false);document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};return/*#__PURE__*/React.createElement("div",{className:"knob-container cursor-ns-resize flex flex-col items-center",onMouseDown:handleMouseDown,title:`Volume: ${Math.round(value*100)}%`},/*#__PURE__*/React.createElement("svg",{className:"w-7 h-7",viewBox:"0 0 40 40"},/*#__PURE__*/React.createElement("circle",{cx:"20",cy:"20",r:"16",fill:"#141414",stroke:"#444",strokeWidth:"2"}),/*#__PURE__*/React.createElement("g",{transform:`rotate(${rotation} 20 20)`,className:"knob-dial"},/*#__PURE__*/React.createElement("line",{x1:"20",y1:"20",x2:"20",y2:"6",stroke:"#ef4444",strokeWidth:"3",strokeLinecap:"round"}))));};const WaveformLane=({track,zoom,timelineWidth,viewportWidth,onSelectRange,onPlayheadSet,isSelected,onSelectTrack,markers,selectionMode,localSelectionTrackId,localSelectionStart,currentTime,getLocalAnchor,onClearLocalSelection,onSetSelectionMode,onSetSelectionStart,onSetSelectionEnd,onSetCurrentTime,onSetLocalSelectionTrackId,onSetLocalSelectionStart,onSetLocalSelectionEnd,localSelLeft,localSelRight,onTrackLaneMouseDown,onContextMenu,onClipDragStart,onClipStretchStart,onSectionItemDragStart,onSectionItemResizeStart,onEditSectionInTab,onEditMidiInTab,onSelectionEdgeDragStart,setSelectedClipId,selectedClipId,activeTool,onSplitTrackAtTime,onEditClipInSubTab,snapValue,bpm,scrollLeft})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const scrollLeftVal=scrollLeft||0;const vWidth=viewportWidth||1200;const height=canvas.parentElement?canvas.parentElement.clientHeight:96;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle=isSelected?'#2a2a2a':track.id%2===0?'#181818':'#1d1d1d';ctx.fillRect(0,0,drawWidth,height);// Grid lines based on Snap value +ctx.strokeStyle='rgba(255, 255, 255, 0.03)';ctx.lineWidth=1;const tStart=scrollLeft/zoom;const tEnd=(scrollLeft+drawWidth)/zoom;let gridSpacing=1.0;if(snapValue&&snapValue!=='free'){const beatDuration=60/parseFloat(bpm||120);let divisor=1;if(snapValue==='1')divisor=1;else if(snapValue==='1/2')divisor=0.5;else if(snapValue==='1/4')divisor=0.25;else if(snapValue==='1/8')divisor=0.125;else if(snapValue==='1/16')divisor=0.0625;else if(snapValue==='1/32')divisor=0.03125;gridSpacing=beatDuration*divisor;}else{gridSpacing=60/parseFloat(bpm||120);}let drawSpacing=gridSpacing;while(drawSpacing*zoom<10){drawSpacing*=2;}const firstGridStep=Math.floor(tStart/drawSpacing)*drawSpacing;for(let s=firstGridStep;s<=tEnd;s+=drawSpacing){const localX=(s-tStart)*zoom;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();}// Draw waveform lane +const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];if(clips.length>0){clips.forEach(clip=>{const numChannels=clip.buffer.numberOfChannels||1;const dataL=clip.buffer.getChannelData(0);const dataR=numChannels>=2?clip.buffer.getChannelData(1):dataL;const sampleRate=clip.buffer.sampleRate;const totalSamples=dataL.length;const originalDuration=totalSamples/sampleRate;const clipSpeed=clip.speed||1.0;const duration=originalDuration/clipSpeed;const clipStartTime=clip.startTime||0;const clipEndTime=clipStartTime+duration;// Culling: Skip rendering if clip is outside visible viewport window +if(clipEndTimetEnd)return;const xStartGlobal=clipStartTime*zoom;const wClip=duration*zoom;const xStartLocal=xStartGlobal-scrollLeft;const xEndLocal=xStartLocal+wClip;// 1. Draw Clip Layer Background & Border +const clipIdentifier=clip.id==='default'?'default_'+track.id:clip.id;const isClipSelected=selectedClipId&&selectedClipId.trackId===track.id&&selectedClipId.clipId===clipIdentifier;ctx.fillStyle=isClipSelected?track.color?track.color+'44':'rgba(6, 182, 212, 0.30)':track.color?track.color+'22':'rgba(6, 182, 212, 0.12)';ctx.strokeStyle=isClipSelected?'#fbbf24':track.color||'#06b6d4';ctx.lineWidth=isClipSelected?1:1.5;const clipTop=4;const clipHeight=height-8;ctx.beginPath();if(ctx.roundRect){ctx.roundRect(xStartLocal,clipTop,wClip,clipHeight,4);}else{ctx.rect(xStartLocal,clipTop,wClip,clipHeight);}ctx.fill();ctx.stroke();// 2. Draw Clip Label & Speed Label +ctx.fillStyle='#e4e4e7';ctx.font='bold 9px sans-serif';ctx.fillText(clip.name||'Clip',Math.max(xStartLocal+8,8),clipTop+12);if(clipSpeed!==1.0){ctx.fillStyle='#fbbf24';ctx.font='bold 8px sans-serif';ctx.fillText(`Speed: ${(clipSpeed*100).toFixed(1)}%`,Math.max(xStartLocal+8,8),clipTop+22);}// Draw markers +if(markers&&markers.length>0){markers.forEach(m=>{const mxLocal=m.time*zoom-scrollLeft;if(mxLocal>=0&&mxLocal<=drawWidth){ctx.fillStyle='#fbbf24';ctx.fillRect(mxLocal-1,0,2,height);}});}// 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match) +const drawXStartLocal=Math.max(0,Math.floor(xStartLocal));const drawXEndLocal=Math.min(drawWidth,Math.ceil(xEndLocal));const samplesPerPixel=sampleRate/zoom*clipSpeed;const isStereo=numChannels>=2;const channelConfigs=isStereo?[{data:dataL,mid:height/4,chHeight:height/2-8,label:'1'},{data:dataR,mid:3*height/4,chHeight:height/2-8,label:'2'}]:[{data:dataL,mid:height/2,chHeight:clipHeight-12,label:'MONO'}];if(isStereo){ctx.strokeStyle='rgba(255, 255, 255, 0.12)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(xStartLocal,height/2);ctx.lineTo(xStartLocal+wClip,height/2);ctx.stroke();}channelConfigs.forEach(ch=>{const data=ch.data;const mid=ch.mid;const peakRatio=ch.chHeight*0.42;// Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB) +const yPlus6=mid-peakRatio*0.8;const yMinus6=mid+peakRatio*0.8;ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(xStartLocal,yPlus6);ctx.lineTo(xStartLocal+wClip,yPlus6);ctx.stroke();ctx.beginPath();ctx.moveTo(xStartLocal,mid);ctx.lineTo(xStartLocal+wClip,mid);ctx.stroke();ctx.beginPath();ctx.moveTo(xStartLocal,yMinus6);ctx.lineTo(xStartLocal+wClip,yMinus6);ctx.stroke();// Decibel Text Labels (Sound Forge Monospace) +ctx.fillStyle='#71717a';ctx.font='8px monospace';const labelX=Math.max(xStartLocal+4,4);ctx.fillText('+6.0',labelX,yPlus6-2);ctx.fillText('-Inf',labelX,mid-2);ctx.fillText('-6.0',labelX,yMinus6+8);// Sound Forge Channel ID Badge (1 or 2) +if(isStereo){ctx.fillStyle='rgba(6, 182, 212, 0.85)';ctx.font='bold 9px monospace';ctx.fillText(ch.label,Math.min(xStartLocal+wClip-12,drawWidth-16),mid-ch.chHeight*0.35);}ctx.strokeStyle='#5bc0be';// Cornflower Blue +ctx.lineWidth=1.2;if(samplesPerPixel<4){// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) +const visibleTStart=(drawXStartLocal-xStartLocal)/zoom;const visibleTEnd=(drawXEndLocal-xStartLocal)/zoom;const startSample=Math.max(0,Math.floor(visibleTStart*clipSpeed*sampleRate));const endSample=Math.min(totalSamples,Math.ceil(visibleTEnd*clipSpeed*sampleRate));ctx.beginPath();let first=true;const maxSamples=50000;const vectorStep=Math.max(1,Math.floor((endSample-startSample)/maxSamples));for(let i=startSample;imaxVal)maxVal=abs;}const peakHeight=maxVal*peakRatio;ctx.beginPath();ctx.moveTo(pxLocal,mid-peakHeight);ctx.lineTo(pxLocal,mid+peakHeight);ctx.stroke();}}});});}else{ctx.fillStyle='#444';ctx.font='12px Inter, sans-serif';ctx.textAlign='center';ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này',drawWidth/2,height/2);}// Draw sections +const sections=track.sections||[];sections.forEach(sec=>{const secStartLocal=sec.start*zoom-scrollLeft;const secWidth=sec.duration*zoom;if(secStartLocal+secWidth<0||secStartLocal>drawWidth)return;ctx.fillStyle=sec.color?sec.color+'44':'rgba(6, 182, 212, 0.25)';ctx.fillRect(secStartLocal,2,secWidth,height-4);ctx.strokeStyle=sec.color||'#06b6d4';ctx.lineWidth=1;ctx.setLineDash([4,4]);ctx.strokeRect(secStartLocal,2,secWidth,height-4);ctx.setLineDash([]);ctx.fillStyle='#e4e4e7';ctx.font='bold 9px sans-serif';ctx.fillText(sec.name||'Section',Math.max(secStartLocal+4,4),14);});// Draw MIDI items +const midiItems=track.midiItems||[];midiItems.forEach(midi=>{const midiStartLocal=midi.startTime*zoom-scrollLeft;const midiWidth=midi.duration*zoom;if(midiStartLocal+midiWidth<0||midiStartLocal>drawWidth)return;ctx.fillStyle='#a78bfa33';ctx.fillRect(midiStartLocal,2,midiWidth,height-4);ctx.strokeStyle='#a78bfa';ctx.lineWidth=1.5;ctx.strokeRect(midiStartLocal,2,midiWidth,height-4);ctx.fillStyle='#c4b5fd';ctx.font='bold 9px sans-serif';ctx.fillText(midi.name||'MIDI',Math.max(midiStartLocal+4,4),14);});// Selection highlight - local selection on this track +if(selectionMode==='local'&&localSelectionTrackId===track.id&&localSelLeft!==null&&localSelRight!==null&&localSelRight>localSelLeft){const hlLeftLocal=localSelLeft*zoom-scrollLeft;const hlWidth=(localSelRight-localSelLeft)*zoom;ctx.fillStyle='rgba(245, 158, 11, 0.15)';ctx.fillRect(hlLeftLocal,0,hlWidth,height);ctx.strokeStyle='#f59e0b';ctx.lineWidth=1;ctx.strokeRect(hlLeftLocal,0,hlWidth,height);}},[track,zoom,timelineWidth,viewportWidth,isSelected,markers,selectionMode,localSelectionTrackId,localSelLeft,localSelRight,snapValue,bpm,scrollLeft]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"virtual-spacer",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseMove:e=>{if(!canvasRef.current)return;const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=x/zoom;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform +if(e.shiftKey&&e.buttons>0){e.preventDefault();e.stopPropagation();const currentAnchor=getLocalAnchor?getLocalAnchor():null;const anchor=currentAnchor!==null&¤tAnchor!==undefined?currentAnchor:localSelectionStart!==null?localSelectionStart:currentTime;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);if(onSetSelectionMode)onSetSelectionMode('local');if(onSetLocalSelectionTrackId)onSetLocalSelectionTrackId(track.id);if(onSetLocalSelectionStart)onSetLocalSelectionStart(selS);if(onSetLocalSelectionEnd)onSetLocalSelectionEnd(selE);if(onSetSelectionStart)onSetSelectionStart(selS);if(onSetSelectionEnd)onSetSelectionEnd(selE);if(onSetCurrentTime)onSetCurrentTime(time);if(onSelectTrack)onSelectTrack(track.id);return;}// Check if hovering near local selection boundaries of this track +const isLocal=selectionMode==='local'&&localSelectionTrackId===track.id;if(isLocal&&localSelLeft!==null&&localSelRight!==null){const leftPx=localSelLeft*zoom;const rightPx=localSelRight*zoom;const distToLeft=Math.abs(x-leftPx);const distToRight=Math.abs(x-rightPx);if(distToLeft<=5||distToRight<=5){canvasRef.current.style.cursor='ew-resize';return;}}// Check if hovering near right edge of a clip for time-stretching (Alt key required) +const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){canvasRef.current.style.cursor='ew-resize';return;}// Check section/MIDI item hover for resize or drag +const allSections=track.sections||[];const allMidiItems=track.midiItems||[];const sectionTolerance=8/zoom;let foundSectionItem=null;let sectionItemEdge=null;const checkEdge=(item,startTime,dur)=>{const leftEdge=Math.abs(time-startTime)<=sectionTolerance;const rightEdge=Math.abs(time-(startTime+dur))<=sectionTolerance;if(leftEdge||rightEdge)return leftEdge?'left':'right';return null;};for(const sec of allSections){const edge=checkEdge(sec,sec.start,sec.duration);if(edge){foundSectionItem={type:'section',item:sec};sectionItemEdge=edge;break;}}if(!foundSectionItem){for(const midi of allMidiItems){const edge=checkEdge(midi,midi.startTime,midi.duration);if(edge){foundSectionItem={type:'midiItem',item:midi};sectionItemEdge=edge;break;}}}if(foundSectionItem&§ionItemEdge){canvasRef.current.style.cursor='ew-resize';return;}// Check body hover for drag +if(!foundSectionItem){for(const sec of allSections){if(time>=sec.start&&time=midi.startTime&&timetime>=c.startTime&&time{// Ignore right-click for local selection drag (context menu handles it) +if(e.button===2)return;const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);onSelectTrack(track.id);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Check if Ctrl+Click to exit selection +if(e.ctrlKey&&selectionMode){e.preventDefault();e.stopPropagation();if(onClearLocalSelection)onClearLocalSelection();if(onSetSelectionMode)onSetSelectionMode(null);if(onSetSelectionStart)onSetSelectionStart(null);if(onSetSelectionEnd)onSetSelectionEnd(null);return;}// 1. Shift+Click (TOP PRIORITY): Range selection on track waveform +if(e.shiftKey){e.preventDefault();e.stopPropagation();const currentAnchor=getLocalAnchor?getLocalAnchor():null;const anchor=currentAnchor!==null&¤tAnchor!==undefined?currentAnchor:localSelectionStart!==null?localSelectionStart:currentTime;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);if(onSetSelectionMode)onSetSelectionMode('local');if(onSetLocalSelectionTrackId)onSetLocalSelectionTrackId(track.id);if(onSetLocalSelectionStart)onSetLocalSelectionStart(selS);if(onSetLocalSelectionEnd)onSetLocalSelectionEnd(selE);if(onSetSelectionStart)onSetSelectionStart(selS);if(onSetSelectionEnd)onSetSelectionEnd(selE);if(onSetCurrentTime)onSetCurrentTime(time);if(onSelectTrack)onSelectTrack(track.id);return;}// Check if dragging selection boundaries (local mode) +const isLocal=selectionMode==='local'&&localSelectionTrackId===track.id;if(isLocal&&localSelLeft!==null&&localSelRight!==null){const leftPx=localSelLeft*zoom;const rightPx=localSelRight*zoom;const distToLeft=Math.abs(x-leftPx);const distToRight=Math.abs(x-rightPx);if(distToLeft<=5){e.preventDefault();e.stopPropagation();if(onSelectionEdgeDragStart)onSelectionEdgeDragStart(e,track.id,'left');return;}else if(distToRight<=5){e.preventDefault();e.stopPropagation();if(onSelectionEdgeDragStart)onSelectionEdgeDragStart(e,track.id,'right');return;}}// Check if time-stretching (Alt + Right Edge) +const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){e.preventDefault();e.stopPropagation();if(onClipStretchStart){onClipStretchStart(track.id,rightEdgeClip.id,time);}return;}// Check section/MIDI item edge for resize, then body for drag +const secItems=track.sections||[];const midiItems=track.midiItems||[];const secTol=8/zoom;let hitItem=null;let hitEdge=null;for(const sec of secItems){if(Math.abs(time-sec.start)<=secTol){hitItem={type:'section',id:sec.id,start:sec.start,dur:sec.duration};hitEdge='left';break;}if(Math.abs(time-(sec.start+sec.duration))<=secTol){hitItem={type:'section',id:sec.id,start:sec.start,dur:sec.duration};hitEdge='right';break;}}if(!hitItem){for(const midi of midiItems){if(Math.abs(time-midi.startTime)<=secTol){hitItem={type:'midiItem',id:midi.id,start:midi.startTime,dur:midi.duration};hitEdge='left';break;}if(Math.abs(time-(midi.startTime+midi.duration))<=secTol){hitItem={type:'midiItem',id:midi.id,start:midi.startTime,dur:midi.duration};hitEdge='right';break;}}}if(hitItem&&hitEdge){e.preventDefault();e.stopPropagation();if(onSectionItemResizeStart)onSectionItemResizeStart(track.id,hitItem.type,hitItem.id,hitEdge,time);return;}if(!hitItem){for(const sec of secItems){if(time>=sec.start&&time=midi.startTime&&timetime>=c.startTime&&time{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Check double-click on section first +const dblSecItems=track.sections||[];let dblSecHit=null;for(const sec of dblSecItems){if(time>=sec.start&&time=midi.startTime&&timetime>=c.startTime&&time{e.preventDefault();e.stopPropagation();onSelectTrack(track.id);const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);// Detect section under cursor +const secList=track.sections||[];let hitSectionId=null;for(const sec of secList){if(time>=sec.start&&time{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const scrollLeftVal=scrollLeft||0;const height=canvas.parentElement?canvas.parentElement.clientHeight:40;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,height);const beatDuration=60/bpm;const barDuration=beatDuration*4;const tStart=scrollLeft/zoom;const tEnd=(scrollLeft+drawWidth)/zoom;const firstBeat=Math.floor(tStart/beatDuration)*beatDuration;for(let t=firstBeat;t<=tEnd;t+=beatDuration){const beatNum=Math.floor(t/beatDuration)+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(isBar){ctx.strokeStyle='rgba(255, 255, 255, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.7)';ctx.font='bold 9px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.ceil(beatNum/4)}`,localX+3,11);}else if(zoom>=2){ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();if(zoom>=5){ctx.fillStyle='rgba(255, 255, 255, 0.4)';ctx.font='8px Inter, sans-serif';ctx.textAlign='left';const bar=Math.ceil(beatNum/4);const beat=(beatNum-1)%4+1;ctx.fillText(`${bar}:${beat}`,localX+2,9);}}else{ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();}}// Draw snap sub-ticks at the bottom +if(snapValue&&snapValue!=='free'){ctx.strokeStyle='rgba(255, 255, 255, 0.15)';ctx.lineWidth=0.8;let divisor=1;if(snapValue==='1')divisor=1;else if(snapValue==='1/2')divisor=0.5;else if(snapValue==='1/4')divisor=0.25;else if(snapValue==='1/8')divisor=0.125;else if(snapValue==='1/16')divisor=0.0625;else if(snapValue==='1/32')divisor=0.03125;const snapInterval=beatDuration*divisor;if(snapInterval*zoom>=4){const firstSnap=Math.floor(tStart/snapInterval)*snapInterval;for(let t=firstSnap;t<=tEnd;t+=snapInterval){const onBeat=Math.abs(t/beatDuration-Math.round(t/beatDuration))<0.001;if(!onBeat){const localX=(t-tStart)*zoom;ctx.beginPath();ctx.moveTo(localX,height-6);ctx.lineTo(localX,height);ctx.stroke();}}}}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='bold 10px Inter, sans-serif';ctx.textAlign='right';ctx.fillText(`${bpm} BPM`,drawWidth-6,12);},[bpm,zoom,timelineWidth,viewportWidth,snapValue,scrollLeft]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"virtual-spacer-tempo",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown){onRulerMouseDown(e);}else{onPlayheadSet(time,e.shiftKey);}}}));};// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── +const SubTabWaveform=({buffer,subTabId,activeTab,currentTime,selectionStart,selectionEnd,onSelectRange,onPlayheadSet,onContextMenu,activeTool,zoom,timelineWidth,color,name,speed=1.0,onSpeedChange,volumeNodes=[],panningNodes=[],fadeInLen=0,fadeOutLen=0,graphMode=null,onUpdateNodes,onUpdateFade,onModeToggle,selectedNodeTime,setSelectedNodeTime,channelInfo=null})=>{const canvasRef=useRef(null);const isStretchingRef=useRef(false);const stretchStartRef=useRef({mouseX:0,originalDuration:0,originalSpeed:1.0});const subTabAnchorRef=useRef(null);const isStereo=channelInfo?channelInfo.isStereo:buffer&&buffer.numberOfChannels>=2;const channelLabel=channelInfo?channelInfo.label:isStereo?'STEREO':'MONO';// Mono: force volume mode (panning not applicable) +const effectiveGraphMode=!isStereo&&graphMode==='pan'?null:graphMode;useEffect(()=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const wrapper=canvas.parentElement?canvas.parentElement.parentElement:null;const scrollLeft=wrapper?wrapper.scrollLeft:0;const vWidth=wrapper?wrapper.clientWidth:1200;const drawWidth=Math.min(timelineWidth,Math.max(vWidth,1200));const h=canvas.parentElement?canvas.parentElement.clientHeight:200;canvas.width=Math.round(drawWidth*dpr);canvas.height=Math.round(h*dpr);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.position='absolute';canvas.style.left=`${scrollLeft}px`;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${h}px`;ctx.fillStyle='#181818';ctx.fillRect(0,0,drawWidth,h);const data=buffer.getChannelData(0);const len=data.length;if(len===0)return;// Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline +const computeHermiteTangents=pts=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i1?(pts[1].db-pts[0].db)/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(pts[n-1].db-pts[n-2].db)/(pts[n-1].time-pts[n-2].time):0;return m;};const getVolumeDbAtTime=t=>{const volNodes=volumeNodes||[];if(volNodes.length===0)return 0.0;const sortedNodes=[...volNodes].sort((a,b)=>a.time-b.time);if(sortedNodes.length===1)return sortedNodes[0].db;if(t<=sortedNodes[0].time)return sortedNodes[0].db;if(t>=sortedNodes[sortedNodes.length-1].time)return sortedNodes[sortedNodes.length-1].db;const tangents=computeHermiteTangents(sortedNodes);for(let i=0;i=n1.time&&t<=n2.time){const h=n2.time-n1.time;if(h<=0)return n1.db;const frac=(t-n1.time)/h;const frac2=frac*frac,frac3=frac2*frac;return(2*frac3-3*frac2+1)*n1.db+(frac3-2*frac2+frac)*h*tangents[i]+(-2*frac3+3*frac2)*n2.db+(frac3-frac2)*h*tangents[i+1];}}return 0.0;};const getVolumeGainAtTime=t=>{return Math.pow(10,getVolumeDbAtTime(t)/20);};const computeHermiteTangentsForPan=pts=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i1?(pts[1].pan-pts[0].pan)/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(pts[n-1].pan-pts[n-2].pan)/(pts[n-1].time-pts[n-2].time):0;return m;};const getPanningValueAtTime=t=>{const panNodes=panningNodes||[];if(panNodes.length===0)return 0;const sortedNodes=[...panNodes].sort((a,b)=>a.time-b.time);if(sortedNodes.length===1)return Math.round(sortedNodes[0].pan*100);if(t<=sortedNodes[0].time)return Math.round(sortedNodes[0].pan*100);if(t>=sortedNodes[sortedNodes.length-1].time)return Math.round(sortedNodes[sortedNodes.length-1].pan*100);const tangents=computeHermiteTangentsForPan(sortedNodes);for(let i=0;i=n1.time&&t<=n2.time){const h=n2.time-n1.time;if(h<=0)return Math.round(n1.pan*100);const frac=(t-n1.time)/h;const frac2=frac*frac,frac3=frac2*frac;const val=(2*frac3-3*frac2+1)*n1.pan+(frac3-2*frac2+frac)*h*tangents[i]+(-2*frac3+3*frac2)*n2.pan+(frac3-frac2)*h*tangents[i+1];return Math.round(val*100);}}return 0;};// Draw clip container (like main session clips) +const xStart=0;const wClip=buffer.duration/speed*zoom;// speed-adjusted width +const xEnd=xStart+wClip;const clipTop=8;const clipHeight=h-16;const clipColor=color||'#06b6d4';ctx.fillStyle=clipColor+'22';ctx.strokeStyle=clipColor;ctx.lineWidth=1.5;if(ctx.roundRect){ctx.beginPath();ctx.roundRect(xStart,clipTop,wClip,clipHeight,4);ctx.fill();ctx.stroke();}else{ctx.fillRect(xStart,clipTop,wClip,clipHeight);ctx.strokeRect(xStart,clipTop,wClip,clipHeight);}// Draw clip label with speed % +ctx.fillStyle='#e4e4e7';ctx.font='bold 10px sans-serif';let displayName=name||'Audio Clip';if(speed!==1.0){displayName+=` (${Math.round(speed*100)}%)`;}ctx.fillText(displayName,xStart+8,clipTop+14);// ── Graph Grid & Axes ── +const drawGrid=true;if(drawGrid){// Background fill +ctx.fillStyle='#181818';ctx.fillRect(xStart,clipTop,wClip,clipHeight);// Vertical grid lines (time markers) +ctx.strokeStyle='#2a2a2a';ctx.lineWidth=0.5;ctx.setLineDash([]);const timeStep=Math.max(0.1,Math.ceil(buffer.duration/20*10)/10);for(let t=0;t<=buffer.duration;t+=timeStep){const px=t*zoom;ctx.beginPath();ctx.moveTo(px,clipTop);ctx.lineTo(px,clipTop+clipHeight);ctx.stroke();}// Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown) +const volZeroY=clipTop+1/3*clipHeight;const panZeroY=clipTop+1/2*clipHeight;// White line for volume 0dB +ctx.strokeStyle='#ffffff';ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(xStart,volZeroY);ctx.lineTo(xStart+wClip,volZeroY);ctx.stroke();// Brown line for panning center +ctx.strokeStyle='#854d0e';ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(xStart,panZeroY);ctx.lineTo(xStart+wClip,panZeroY);ctx.stroke();// Horizontal grid lines (other value markers) +const isPanMode=effectiveGraphMode==='pan';if(isPanMode){for(let p=-100;p<=100;p+=20){if(p===0)continue;const y=clipTop+clipHeight*(1-(p/100+1)/2);ctx.strokeStyle='#2a2a2a';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(xStart,y);ctx.lineTo(xStart+wClip,y);ctx.stroke();}}else{for(let db=-30;db<=3;db+=3){if(db===0)continue;const y=db>=0?volZeroY-db/3*(2/3*clipHeight):volZeroY+-db/30*(1/3*clipHeight);ctx.strokeStyle='#2a2a2a';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(xStart,y);ctx.lineTo(xStart+wClip,y);ctx.stroke();}}// Y-axis labels (left side) +ctx.fillStyle='#71717a';ctx.font='7px monospace';ctx.textAlign='right';if(isPanMode){ctx.fillText('L100',xStart-2,clipTop+8);ctx.fillText('R50',xStart-2,clipTop+clipHeight*0.25+2);ctx.fillStyle='#854d0e';// Brown label for active Panning Center +ctx.fillText('C (Pan)',xStart-2,clipTop+clipHeight*0.5+2);ctx.fillStyle='#71717a';ctx.fillText('L50',xStart-2,clipTop+clipHeight*0.75+2);ctx.fillText('R100',xStart-2,clipTop+clipHeight-2);}else{ctx.fillText('+3dB',xStart-2,clipTop+8);ctx.fillStyle='#ffffff';// White label for active Volume 0dB +ctx.fillText('0dB (Vol)',xStart-2,volZeroY+2);ctx.fillStyle='#71717a';ctx.fillText('-15dB',xStart-2,volZeroY+clipHeight/6+2);ctx.fillText('-30dB',xStart-2,clipTop+clipHeight-2);}ctx.textAlign='start';}// Drag hint labels for fade endpoints +ctx.fillStyle='#71717a';ctx.font='8px sans-serif';ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh',8,clipTop+clipHeight+12);// Display speed percentage in the bottom left corner of the waveform area +ctx.fillStyle='#e4e4e7';ctx.font='bold 9px sans-serif';ctx.fillText(`Tốc độ: ${Math.round(speed*100)}%`,xStart+8,clipTop+clipHeight-8);// Mode toggle button on waveform (bottom-right) +const modeBtnW=28;const modeBtnH=14;const modeBtnX=wClip-modeBtnW-4;const modeBtnY=clipTop+clipHeight-modeBtnH-2;const isPanMode=effectiveGraphMode==='pan';ctx.fillStyle=isPanMode?'rgba(168, 85, 247, 0.5)':'rgba(6, 182, 212, 0.5)';ctx.beginPath();ctx.roundRect(modeBtnX,modeBtnY,modeBtnW,modeBtnH,3);ctx.fill();ctx.fillStyle='#fff';ctx.font='bold 7px sans-serif';ctx.textAlign='center';ctx.fillText(isPanMode?'PAN':'VOL',modeBtnX+modeBtnW/2,modeBtnY+10);ctx.textAlign='start';// Channel label (L / R for stereo, M for mono) +ctx.fillStyle='#a1a1aa';ctx.font='bold 8px monospace';if(isStereo){ctx.fillText('L',xStart+2,clipTop+clipHeight*0.28);ctx.fillText('R',xStart+2,clipTop+clipHeight*0.72);}else{ctx.fillText('M',xStart+2,clipTop+clipHeight/2);}// Draw waveform inside clip (speed-adjusted) with fade envelope applied +const drawXStart=Math.max(0,Math.floor(xStart));const drawXEnd=Math.min(drawWidth,Math.ceil(xEnd));const samplesPerPixel=buffer.sampleRate/zoom*speed;const bufDur=buffer.duration;const mid=h/2;const peakRatio=clipHeight*0.45;const getFadeGainAtTime=t=>{if(fadeInLen>0&&t0&&t>bufDur-fadeOutLen){const ratio=(t-(bufDur-fadeOutLen))/fadeOutLen;return(1+Math.cos(Math.PI*ratio))/2;}return 1;};ctx.strokeStyle='#5bc0be';ctx.lineWidth=1.2;if(samplesPerPixel<4){// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) +const startSample=Math.max(0,Math.floor((drawXStart-xStart)/zoom*speed*buffer.sampleRate));const endSample=Math.min(len,Math.ceil((drawXEnd-xStart)/zoom*speed*buffer.sampleRate));ctx.beginPath();let first=true;const maxSamples=50000;const vectorStep=Math.max(1,Math.floor((endSample-startSample)/maxSamples));for(let i=startSample;imaxVal)maxVal=val;if(val{const db=typeof node==='number'?node:node.db;const zeroY=clipTop+1/3*clipHeight;return db>=0?zeroY-db/3*(1/3*clipHeight):zeroY+-db/30*(2/3*clipHeight);};// Panning: 0 at center +const autoPanY=node=>{const pan=typeof node==='number'?node:node.pan;return clipTop+(1-(pan+1)/2)*clipHeight;};// Helper: compute tangents for monotone Hermite spline +const computeTangents=(pts,yFn)=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i1?(yFn(pts[1])-yFn(pts[0]))/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(yFn(pts[n-1])-yFn(pts[n-2]))/(pts[n-1].time-pts[n-2].time):0;return m;};// Helper: evaluate Hermite at pixel position px +const hermiteY=(px,x0,y0,m0,x1,y1,m1)=>{const h=x1-x0;if(h<=0)return y0;const t=(px-x0)/h;const t2=t*t,t3=t2*t;return(2*t3-3*t2+1)*y0+(t3-2*t2+t)*h*m0+(-2*t3+3*t2)*y1+(t3-t2)*h*m1;};// Draw automation curve with Hermite spline +const drawSpline=(nodes,yFn,color,lineDash)=>{if(nodes.length<2){if(nodes.length===1){const px=nodes[0].time*zoom;const y=yFn(nodes[0]);ctx.fillStyle=color;ctx.beginPath();ctx.arc(px,y,4,0,Math.PI*2);ctx.fill();}return;}const tangents=computeTangents(nodes,yFn);ctx.strokeStyle=color;ctx.lineWidth=2;ctx.setLineDash(lineDash||[]);ctx.beginPath();for(let i=0;i{const px=n.time*zoom,y=yFn(n);const isSelected=n.time===selectedNodeTime;ctx.fillStyle=isSelected?'#ffebb3':'#fff';ctx.beginPath();ctx.arc(px,y,isSelected?6:4,0,Math.PI*2);ctx.fill();ctx.strokeStyle=isSelected?'#fbbf24':color;ctx.lineWidth=isSelected?2.5:1.5;ctx.beginPath();ctx.arc(px,y,isSelected?6:4,0,Math.PI*2);ctx.stroke();// Display value at node +ctx.fillStyle='#ffffff';ctx.font='bold 12px sans-serif';const labelText=isPanMode?n.pan>0?'R'+Math.round(n.pan*100):n.pan<0?'L'+Math.round(Math.abs(n.pan)*100):'C':`${n.db>=0?'+':''}${n.db.toFixed(1)}dB`;ctx.fillText(labelText,px+8,y+4);});};drawSpline(volumeNodes,autoY,'#f43f5e');drawSpline(panningNodes,autoPanY,'#a855f7',[4,4]);// ── Fade Curves (transparent, only curve lines + endpoint handles) ── +const FADE_COLOR='#b91c1c';const HANDLE_RADIUS=5;if(fadeInLen>=0){const fiPx=fadeInLen*zoom;if(fadeInLen>0){ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.8;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(0,clipTop+clipHeight);for(let px=0;px<=fiPx;px++){const ratio=px/fiPx;const amp=(1-Math.cos(Math.PI*ratio))/2;const y=clipTop+clipHeight-amp*clipHeight;ctx.lineTo(px,y);}ctx.stroke();}// Endpoint handle (draggable) +const endX=fiPx,endY=clipTop;ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(endX,endY,HANDLE_RADIUS,0,Math.PI*2);ctx.fill();ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(endX,endY,HANDLE_RADIUS,0,Math.PI*2);ctx.stroke();// Label +ctx.fillStyle='#b91c1c';ctx.font='bold 7px sans-serif';ctx.textAlign='center';ctx.fillText('FI',endX,endY-HANDLE_RADIUS-3);ctx.textAlign='start';}if(fadeOutLen>=0){const foPx=fadeOutLen*zoom;const startX=wClip-foPx;if(fadeOutLen>0){ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.8;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(startX,clipTop);for(let px=0;px<=foPx;px++){const ratio=px/foPx;const amp=(1+Math.cos(Math.PI*ratio))/2;const y=clipTop+clipHeight-amp*clipHeight;ctx.lineTo(startX+px,y);}ctx.stroke();}// Endpoint handle (draggable) +const handleX=startX,handleY=clipTop;ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(handleX,handleY,HANDLE_RADIUS,0,Math.PI*2);ctx.fill();ctx.strokeStyle=FADE_COLOR;ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(handleX,handleY,HANDLE_RADIUS,0,Math.PI*2);ctx.stroke();ctx.fillStyle='#b91c1c';ctx.font='bold 7px sans-serif';ctx.textAlign='center';ctx.fillText('FO',handleX,handleY-HANDLE_RADIUS-3);ctx.textAlign='start';}// Draw right-edge stretch handle indicator +if(wClip>0&&wClip=0&¤tTime<=buffer.duration/speed){const playheadPx=currentTime*zoom;ctx.strokeStyle='#ef4444';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(playheadPx,0);ctx.lineTo(playheadPx,h);ctx.stroke();}// Update TCP slider values in real-time to match the curve at currentTime +const volInput=document.getElementById(`tcp-vol-${subTabId}`);const volLabel=document.getElementById(`tcp-vol-label-${subTabId}`);if(volInput&&volLabel){const dbVal=getVolumeDbAtTime(currentTime||0);volInput.value=dbVal.toFixed(1);volLabel.textContent=`${dbVal.toFixed(1)}dB`;}const panInput=document.getElementById(`tcp-pan-${subTabId}`);const panLabel=document.getElementById(`tcp-pan-label-${subTabId}`);if(panInput&&panLabel){const panVal=getPanningValueAtTime(currentTime||0);panInput.value=panVal;panLabel.textContent=panVal>0?'R':panVal<0?'L':'C';const panLabelDetailed=document.getElementById(`tcp-pan-label-detailed-${subTabId}`);if(panLabelDetailed){panLabelDetailed.textContent=panVal>0?'R'+panVal:panVal<0?'L'+Math.abs(panVal):'C';}}},[buffer,currentTime,selectionStart,selectionEnd,zoom,timelineWidth,color,name,speed,volumeNodes,panningNodes,fadeInLen,fadeOutLen,graphMode,selectedNodeTime,subTabId]);const handleMouseDown=e=>{if(e.button===2)return;const canvas=canvasRef.current;const rect=canvas.getBoundingClientRect();const parent=canvas.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const bufDuration=buffer.duration;const wallDuration=bufDuration/(speed||1.0);const mouseX=e.clientX-rect.left+scrollLeft;const startTime=Math.max(0,Math.min(wallDuration,mouseX/zoom));// Shift + Click range selection in SubTab Waveform +if(e.shiftKey){e.preventDefault();e.stopPropagation();const anchor=subTabAnchorRef.current!==null&&subTabAnchorRef.current!==undefined?subTabAnchorRef.current:selectionStart!==null&&selectionStart!==undefined?selectionStart:currentTime;const selS=Math.min(anchor,startTime);const selE=Math.max(anchor,startTime);onSelectRange(selS,selE);onPlayheadSet(startTime);return;}// Alt+Click near right edge → speed stretch +if(e.altKey&&onSpeedChange){const clipRightEdge=bufDuration/(speed||1.0)*zoom;const tolerance=8;if(Math.abs(mouseX-clipRightEdge)<=tolerance){isStretchingRef.current=true;stretchStartRef.current={mouseX,originalDuration:bufDuration,originalSpeed:speed};canvas.style.cursor='ew-resize';const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const wClipPx=stretchStartRef.current.originalDuration/stretchStartRef.current.originalSpeed*zoom;const deltaX=currentX-stretchStartRef.current.mouseX;const newWClip=Math.max(10,wClipPx+deltaX);const newSpeed=stretchStartRef.current.originalDuration/(newWClip/zoom);if(onSpeedChange)onSpeedChange(Math.max(0.05,Math.min(10,newSpeed)));};const handleMouseUp=()=>{isStretchingRef.current=false;canvas.style.cursor='default';document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}}// Mode toggle button click (bottom-right VOL/PAN) +const wClipPx=bufDuration/(speed||1.0)*zoom;const cTop=8;const cSize=16;const cHeight=rect.height-16;const modeBtnX=wClipPx-28-4;const modeBtnY=cTop+cHeight-14-2;if(mouseX>=modeBtnX&&mouseX<=modeBtnX+28&&e.clientY-rect.top>=modeBtnY&&e.clientY-rect.top<=modeBtnY+14){if(onModeToggle)onModeToggle();return;}// Fade endpoint handle drag (click on FI/FO handle circles) +const HANDLE_R=5;const fiEndPx=fadeInLen*zoom;const foStartPx=wClipPx-fadeOutLen*zoom;const distToFiHandle=Math.abs(mouseX-fiEndPx)+Math.abs(e.clientY-rect.top-cTop);const distToFoHandle=Math.abs(mouseX-foStartPx)+Math.abs(e.clientY-rect.top-cTop);if(distToFiHandle<=HANDLE_R+6){const handleMouseMove=moveEvent=>{const x=moveEvent.clientX-rect.left+scrollLeft;const t=Math.max(0,Math.min(wallDuration,x/zoom));if(onUpdateFade)onUpdateFade({fadeInLen:t});};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}if(distToFoHandle<=HANDLE_R+6){const handleMouseMove=moveEvent=>{const x=moveEvent.clientX-rect.left+scrollLeft;const t=Math.max(0,Math.min(wallDuration,(wClipPx-x)/zoom));if(onUpdateFade)onUpdateFade({fadeOutLen:t});};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}// Tool-specific behavior +if(activeTool==='grab'){setSelectedNodeTime(null);onPlayheadSet(startTime);const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));onPlayheadSet(ct);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}if(activeTool==='razor'){setSelectedNodeTime(null);onPlayheadSet(startTime);showToast(`Cut point at ${formatTime(startTime)}`,'info');return;}if(activeTool==='pen'){// Deduplicate by time: keep last occurrence per time key +const mergeNodes=(existing,incoming)=>{const map=new Map();existing.forEach(n=>map.set(n.time,n));incoming.forEach(n=>map.set(n.time,n));return Array.from(map.values()).sort((a,b)=>a.time-b.time);};onPlayheadSet(startTime);canvas.style.cursor='crosshair';const isPan=(graphMode||'volume')==='pan';const isCtrl=e.ctrlKey||e.metaKey;const cTop=8;const cHeight=rect.height-16;const curNodes=isPan?panningNodes:volumeNodes;const valFromY=y=>{if(isPan)return Math.max(-1,Math.min(1,-(y-cTop)/cHeight*2+1));const yOff=(y-cTop)/cHeight;return yOff<=1/3?Math.max(0,Math.min(3,3*(1-yOff*3))):Math.max(-30,Math.min(0,-30*(yOff-1/3)*(3/2)));};const snapVal=v=>isPan?Math.round(v*20)/20:Math.round(v*2)/2;const snapTime=t=>Math.round(t*10)/10;// Check if clicking near existing node (any mode) +const volNodeY=v=>{const z=cTop+1/3*cHeight;return v>=0?z-v/3*(1/3*cHeight):z+-v/30*(2/3*cHeight);};const nearNode=curNodes.findIndex(n=>{const t=Math.abs(n.time-startTime);const val=isPan?n.pan:n.db;const ny=isPan?cTop+(1-(val+1)/2)*cHeight:volNodeY(val);const dy=Math.abs(e.clientY-rect.top-ny);return t<0.1/(speed||1)&&dy<10;});if(nearNode>=0){// Drag existing node +setSelectedNodeTime(curNodes[nearNode].time);let working=[...curNodes];let dragIdx=nearNode;const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const val=snapVal(valFromY(moveEvent.clientY-rect.top));const updated=isPan?{time:Math.min(snapTime(ct),wallDuration),pan:val}:{time:Math.min(snapTime(ct),wallDuration),db:val};const cleaned=mergeNodes(working.filter((_,i)=>i!==dragIdx),[updated]);if(onUpdateNodes)onUpdateNodes(cleaned);working=[...cleaned];dragIdx=working.findIndex(n=>n.time===updated.time&&(isPan?n.pan===updated.pan:n.db===updated.db));// Follow the selected node during dragging +setSelectedNodeTime(updated.time);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);canvas.style.cursor='crosshair';};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return;}if(isCtrl){setSelectedNodeTime(null);const pts=[];let lastKey='';const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const t=+snapTime(ct).toFixed(3);const v=isPan?+snapVal(valFromY(moveEvent.clientY-rect.top)).toFixed(2):+snapVal(valFromY(moveEvent.clientY-rect.top)).toFixed(1);const key=t+'|'+v;if(key!==lastKey){pts.push({time:t,[isPan?'pan':'db']:v});lastKey=key;}};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);canvas.style.cursor='crosshair';const merged=mergeNodes(curNodes,pts);if(onUpdateNodes)onUpdateNodes(merged);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);}else{const newNode=isPan?{time:+snapTime(startTime).toFixed(3),pan:+snapVal(valFromY(e.clientY-rect.top)).toFixed(2)}:{time:+snapTime(startTime).toFixed(3),db:+snapVal(valFromY(e.clientY-rect.top)).toFixed(1)};setSelectedNodeTime(newNode.time);const merged=mergeNodes(curNodes,[newNode]);if(onUpdateNodes)onUpdateNodes(merged);// Now drag this newly created node +let working=[...merged];let dragIdx=working.findIndex(n=>n.time===newNode.time&&(isPan?n.pan===newNode.pan:n.db===newNode.db));const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const val=snapVal(valFromY(moveEvent.clientY-rect.top));const updated=isPan?{time:Math.min(snapTime(ct),wallDuration),pan:val}:{time:Math.min(snapTime(ct),wallDuration),db:val};const cleaned=mergeNodes(working.filter((_,i)=>i!==dragIdx),[updated]);if(onUpdateNodes)onUpdateNodes(cleaned);working=[...cleaned];dragIdx=working.findIndex(n=>n.time===updated.time&&(isPan?n.pan===updated.pan:n.db===updated.db));setSelectedNodeTime(updated.time);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);canvas.style.cursor='crosshair';};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);}return;}// Select tool (default): drag to select range +subTabAnchorRef.current=startTime;setSelectedNodeTime(null);onSelectRange(startTime,startTime);onPlayheadSet(startTime);const handleMouseMove=moveEvent=>{const currentX=moveEvent.clientX-rect.left+scrollLeft;const ct=Math.max(0,Math.min(wallDuration,currentX/zoom));const anchor=subTabAnchorRef.current??startTime;onSelectRange(Math.min(anchor,ct),Math.max(anchor,ct));};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleContextMenuInternal=e=>{e.preventDefault();e.stopPropagation();const canvas=canvasRef.current;const rect=canvas.getBoundingClientRect();const parent=canvas.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const x=e.clientX-rect.left+scrollLeft;const clickTime=Math.max(0,Math.min(buffer.duration/(speed||1.0),x/zoom));onContextMenu(e,clickTime);};// Double-click on automation curve to create a new node +const handleDoubleClick=e=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;if(activeTool!=='select'&&activeTool!=='pen')return;const rect=canvas.getBoundingClientRect();const parent=canvas.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const x=e.clientX-rect.left+scrollLeft;const clickTime=Math.max(0,Math.min(buffer.duration/(speed||1.0),x/zoom));const isPan=(graphMode||'volume')==='pan';const curNodes=isPan?panningNodes:volumeNodes;const cTop=8;const cHeight=rect.height-16;const valFromY=y=>{if(isPan)return Math.max(-1,Math.min(1,-(y-cTop)/cHeight*2+1));const yOff=(y-cTop)/cHeight;return yOff<=2/3?Math.max(0,Math.min(3,3*(1-yOff*3/2))):Math.max(-30,Math.min(0,-30*(yOff-2/3)*3));};// Interpolate value at click position from existing curve +let interpolatedVal=valFromY(e.clientY-rect.top);if(curNodes.length>=2){const sorted=[...curNodes].sort((a,b)=>a.time-b.time);for(let i=0;i=sorted[i].time&&clickTime<=sorted[i+1].time){const t=(clickTime-sorted[i].time)/(sorted[i+1].time-sorted[i].time);const v1=isPan?sorted[i].pan:sorted[i].db;const v2=isPan?sorted[i+1].pan:sorted[i+1].db;interpolatedVal=v1+t*(v2-v1);break;}}}const newNode=isPan?{time:+Math.round(clickTime*10)/10,pan:+Math.round(interpolatedVal*20)/20}:{time:+Math.round(clickTime*10)/10,db:+Math.round(interpolatedVal*2)/2};const merged=(()=>{const map=new Map();curNodes.forEach(n=>map.set(n.time,n));map.set(newNode.time,newNode);return Array.from(map.values()).sort((a,b)=>a.time-b.time);})();if(onUpdateNodes)onUpdateNodes(merged);};return/*#__PURE__*/React.createElement("div",{style:{width:`${timelineWidth}px`,height:'100%',position:'relative',overflow:'hidden'}},/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'absolute',top:0,left:0,imageRendering:'pixelated'},className:"cursor-crosshair rounded border border-zinc-800",onMouseDown:handleMouseDown,onDoubleClick:handleDoubleClick,onContextMenu:handleContextMenuInternal,onMouseMove:e=>{if(canvasRef.current&&e.altKey&&onSpeedChange){const rect=canvasRef.current.getBoundingClientRect();const parent=canvasRef.current.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const mx=e.clientX-rect.left+scrollLeft;const wClip=buffer.duration/speed*zoom;const tolerance=8;canvasRef.current.style.cursor=Math.abs(mx-wClip)<=tolerance&&!isStretchingRef.current?'ew-resize':'crosshair';}else if(canvasRef.current&&!isStretchingRef.current){const rect=canvasRef.current.getBoundingClientRect();const parent=canvasRef.current.parentElement;const scrollContainer=parent?parent.parentElement:null;const scrollLeft=scrollContainer?scrollContainer.scrollLeft:0;const mx=e.clientX-rect.left+scrollLeft;const wClipPx=buffer.duration/(speed||1.0)*zoom;const cH=canvasRef.current.height/(window.devicePixelRatio||1)-16;const btnX=wClipPx-28-4;const btnY=8+cH-14-2;const overBtn=mx>=btnX&&mx<=btnX+28&&e.clientY-rect.top>=btnY&&e.clientY-rect.top<=btnY+14;canvasRef.current.style.cursor=overBtn?'pointer':'crosshair';}}}));};const SubTabToolbar=({st,activeTool,setActiveTool,handleSubTabNormalizeWithValue,handleSubTabGainWithValue,handleSubTabPitch,handleSubTabStretch,handleSubTabFade,onPlayPause,onStop,onRewind,onForward,onLoop,onRecord,onRateChange,onCut,onCopy,onPaste,onGlue,snapValue,onSnapChange})=>{const isPlaying=st?.isPlaying||false;const isLooping=st?.isLooping||false;const isRecording=st?.isRecording||false;return/*#__PURE__*/React.createElement("div",{className:"daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800"},/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='select'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('select'),title:"Select Tool"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"mouse-pointer",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='grab'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('grab'),title:"Grab Tool"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"hand",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='razor'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('razor'),title:"Razor Tool"},/*#__PURE__*/React.createElement("svg",{className:"w-4 h-4 text-orange-400",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round"},/*#__PURE__*/React.createElement("path",{d:"M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"}),/*#__PURE__*/React.createElement("path",{d:"M4 9h16l-3 9H7z"}),/*#__PURE__*/React.createElement("circle",{cx:"12",cy:"6",r:"1"}))),/*#__PURE__*/React.createElement("button",{className:`p-1 rounded ${activeTool==='pen'?'bg-cyan-700':'bg-zinc-700'}`,onClick:()=>setActiveTool('pen'),title:"Pen Tool"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"pen-tool",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-4 bg-zinc-800 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:onGlue,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition",title:"Glue Clips"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"link",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:onCut,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition",title:"Cut (Ctrl+X)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:onCopy,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition",title:"Copy (Ctrl+C)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:onPaste,className:"p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition",title:"Paste (Ctrl+V)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"Snap"),/*#__PURE__*/React.createElement("select",{value:snapValue||'free',onChange:e=>onSnapChange(e.target.value),className:"bg-zinc-850 text-zinc-300 text-xs px-1 py-0.5 rounded border border-zinc-800 focus:outline-none font-mono"},/*#__PURE__*/React.createElement("option",{value:"free"},"Free"),/*#__PURE__*/React.createElement("option",{value:"1"},"1"),/*#__PURE__*/React.createElement("option",{value:"1/2"},"1/2"),/*#__PURE__*/React.createElement("option",{value:"1/4"},"1/4"),/*#__PURE__*/React.createElement("option",{value:"1/8"},"1/8"),/*#__PURE__*/React.createElement("option",{value:"1/16"},"1/16"),/*#__PURE__*/React.createElement("option",{value:"1/32"},"1/32"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Normalize:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",value:st.effects?.normalizeDb||0,step:"0.1",onChange:e=>handleSubTabNormalizeWithValue(st.id,parseFloat(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.normalizeDb||0," dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Gain:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-40",max:"24",value:st.effects?.gainDb||0,step:"0.1",onChange:e=>handleSubTabGainWithValue(st.id,parseFloat(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.gainDb||0," dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Pitch:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"12",value:st.effects?.pitch||0,step:"0.1",onChange:e=>handleSubTabPitch(st.id,parseFloat(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.pitch||0," st")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Stretch:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"50",max:"200",value:st.effects?.speedStretch||100,step:"1",onChange:e=>handleSubTabStretch(st.id,parseInt(e.target.value)),className:"w-20 h-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 w-10 text-right"},st.effects?.speedStretch||100,"%"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("button",{onClick:()=>handleSubTabFade(st.id,'in'),className:"px-2 py-1 text-xs rounded hover:bg-zinc-700"},"Fade In"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleSubTabFade(st.id,'out'),className:"px-2 py-1 text-xs rounded hover:bg-zinc-700"},"Fade Out")),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1 border-r border-zinc-700 pr-3"},/*#__PURE__*/React.createElement("button",{onClick:onRewind,className:"w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Rewind"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-back",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{onClick:onPlayPause,className:`w-8 h-8 flex items-center justify-center rounded border transition ${isPlaying?'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500':'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`,title:isPlaying?"Pause":"Play"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":isPlaying?'pause':'play',className:"w-4 h-4 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:onStop,className:"w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Stop"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-4 h-4 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:onForward,className:"w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Forward"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-forward",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{onClick:onLoop,className:`w-8 h-8 flex items-center justify-center rounded border transition ${isLooping?'bg-amber-600 text-black border-amber-500 hover:bg-amber-500':'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`,title:isLooping?"Loop On":"Loop Off"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("button",{onClick:onRecord,className:`w-8 h-8 flex items-center justify-center rounded border transition ${isRecording?'bg-red-600 text-white border-red-500':'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`,title:isRecording?"Recording":"Record"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"circle",className:"w-4 h-4"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-400"},"Rate:"),/*#__PURE__*/React.createElement("select",{value:st.playbackRate||1,onChange:e=>onRateChange(parseFloat(e.target.value)),className:"bg-zinc-700 text-zinc-100 text-xs px-1 py-0.5 rounded border border-zinc-600"},/*#__PURE__*/React.createElement("option",{value:"0.5"},"0.5x"),/*#__PURE__*/React.createElement("option",{value:"0.75"},"0.75x"),/*#__PURE__*/React.createElement("option",{value:"1"},"1x"),/*#__PURE__*/React.createElement("option",{value:"1.25"},"1.25x"),/*#__PURE__*/React.createElement("option",{value:"1.5"},"1.5x"),/*#__PURE__*/React.createElement("option",{value:"2"},"2x"))));};// ── Graph Editor Canvas for Volume/Pan/Fade Automation ── +const GraphEditorCanvas=({buffer,zoom,timelineWidth,volumeNodes,panningNodes,fadeInLen,fadeOutLen,onUpdateNodes,graphMode})=>{const canvasRef=useRef(null);const isDraggingNode=useRef(false);const dragNodeIdx=useRef(-1);const isCreatingNode=useRef(false);const getNodes=()=>graphMode==='pan'?panningNodes:volumeNodes;const nodeLabel=n=>graphMode==='pan'?`${n.pan.toFixed(2)}`:`${n.db.toFixed(1)}dB`;const nodeY=(n,h)=>{if(graphMode==='pan')return(1-(n.pan+1)/2)*h;const zeroY=2/3*h;return n.db>=0?zeroY-n.db/3*(2/3*h):zeroY+-n.db/30*(1/3*h);};const nodeValFromY=(y,h)=>{if(graphMode==='pan')return-(y/h*2-1);const yOff=y/h;return yOff<=2/3?3*(1-yOff*3/2):-30*(yOff-2/3)*3;};useEffect(()=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const wrapper=canvas.parentElement?canvas.parentElement.parentElement:null;const scrollLeft=wrapper?wrapper.scrollLeft:0;const vWidth=wrapper?wrapper.clientWidth:1200;const drawWidth=Math.min(timelineWidth,Math.max(vWidth,1200));const h=canvas.parentElement?canvas.parentElement.clientHeight:200;canvas.width=Math.round(drawWidth*dpr);canvas.height=Math.round(h*dpr);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.position='absolute';canvas.style.left=`${scrollLeft}px`;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${h}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,h);ctx.strokeStyle='#2a2a4e';ctx.lineWidth=0.5;for(let t=0;t<=buffer.duration;t+=0.5){const x=t/buffer.duration*drawWidth;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke();}for(let i=0;i<=10;i++){const y=i/10*h;ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(drawWidth,y);ctx.stroke();}if(fadeInLen>0){const fadeX=fadeInLen/buffer.duration*drawWidth;ctx.fillStyle='rgba(16, 185, 129, 0.12)';ctx.fillRect(0,0,fadeX,h);}if(fadeOutLen>0){const fadeX=(buffer.duration-fadeOutLen)/buffer.duration*drawWidth;const fadeW=fadeOutLen/buffer.duration*drawWidth;ctx.fillStyle='rgba(239, 68, 68, 0.12)';ctx.fillRect(fadeX,0,fadeW,h);}const nodes=getNodes();if(nodes.length>0){ctx.strokeStyle=graphMode==='pan'?'#a855f7':'#06b6d4';ctx.lineWidth=2;ctx.beginPath();nodes.forEach((n,i)=>{const x=n.time/buffer.duration*drawWidth;const y=nodeY(n,h);if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);});ctx.stroke();nodes.forEach((n,i)=>{const x=n.time/buffer.duration*drawWidth;const y=nodeY(n,h);ctx.fillStyle=graphMode==='pan'?'#a855f7':'#06b6d4';ctx.beginPath();ctx.arc(x,y,5,0,Math.PI*2);ctx.fill();ctx.fillStyle='#e4e4e7';ctx.font='9px monospace';ctx.fillText(nodeLabel(n),x+8,y+3);});}else{ctx.fillStyle='#52525b';ctx.font='11px sans-serif';ctx.textAlign='center';ctx.fillText(graphMode==='pan'?'Click to add Pan points':'Click to add Volume points',drawWidth/2,h/2);ctx.textAlign='start';}const zeroY=graphMode==='pan'?h/2:nodeY({db:0},h);ctx.strokeStyle=graphMode==='pan'?'#a855f744':'#06b6d444';ctx.lineWidth=1;ctx.setLineDash([4,4]);ctx.beginPath();ctx.moveTo(0,zeroY);ctx.lineTo(drawWidth,zeroY);ctx.stroke();ctx.setLineDash([]);},[buffer,zoom,timelineWidth,volumeNodes,panningNodes,fadeInLen,fadeOutLen,graphMode]);const handleMouseDown=e=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const time=x/rect.width*buffer.duration;const val=nodeValFromY(y,rect.height);const nodes=getNodes();const snapped=Math.max(-30,Math.min(3,val));const snappedPan=Math.max(-1,Math.min(1,val));const threshold=12/rect.width*buffer.duration;const nearIdx=nodes.findIndex(n=>Math.abs(n.time-time)=0){isDraggingNode.current=true;dragNodeIdx.current=nearIdx;return;}const newNode=graphMode==='pan'?{time:+time.toFixed(3),pan:+snappedPan.toFixed(2)}:{time:+time.toFixed(3),db:+snapped.toFixed(1)};const sorted=[...nodes,newNode].sort((a,b)=>a.time-b.time);onUpdateNodes(sorted);const newIdx=sorted.findIndex(n=>n.time===newNode.time&&(graphMode==='pan'?n.pan:n.db)===(graphMode==='pan'?newNode.pan:newNode.db));isDraggingNode.current=true;dragNodeIdx.current=newIdx;isCreatingNode.current=true;};const handleMouseMove=e=>{if(!isDraggingNode.current||dragNodeIdx.current<0)return;const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const time=Math.max(0,Math.min(buffer.duration,x/rect.width*buffer.duration));const val=nodeValFromY(y,rect.height);const nodes=[...getNodes()];nodes[dragNodeIdx.current]=graphMode==='pan'?{time:+time.toFixed(3),pan:+Math.max(-1,Math.min(1,val)).toFixed(2)}:{time:+time.toFixed(3),db:+Math.max(-30,Math.min(3,val)).toFixed(1)};onUpdateNodes(nodes.sort((a,b)=>a.time-b.time));};const handleMouseUp=()=>{isDraggingNode.current=false;dragNodeIdx.current=-1;isCreatingNode.current=false;};const handleContextMenu=e=>{e.preventDefault();const canvas=canvasRef.current;if(!canvas||!buffer)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const time=x/rect.width*buffer.duration;const threshold=12/rect.width*buffer.duration;const nodes=getNodes();const nearIdx=nodes.findIndex(n=>Math.abs(n.time-time)=0)onUpdateNodes(nodes.filter((_,i)=>i!==nearIdx));};return/*#__PURE__*/React.createElement("div",{style:{width:`${timelineWidth}px`,height:'100%',position:'relative',overflow:'hidden'}},/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'absolute',top:0,left:0,imageRendering:'pixelated'},className:"cursor-crosshair rounded border border-zinc-700",onMouseDown:handleMouseDown,onMouseMove:handleMouseMove,onMouseUp:handleMouseUp,onMouseLeave:handleMouseUp,onContextMenu:handleContextMenu}));};const AuthModal=({isOpen,mode,forceMandatory,onClose,onSuccess})=>{if(!isOpen)return null;const[activeTab,setActiveTab]=useState(mode||'login');const[username,setUsername]=useState('admin');const[email,setEmail]=useState('');const[password,setPassword]=useState('');const[oldPassword,setOldPassword]=useState('');const[newPassword,setNewPassword]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);useEffect(()=>{if(mode)setActiveTab(mode);if(mode==='force_change'&&!oldPassword){setOldPassword('admin123');}},[mode]);const handleSubmit=async e=>{e.preventDefault();setError('');setLoading(true);try{if(activeTab==='login'){const targetUsername=username.trim()||'admin';const targetPwd=password.trim()||'admin123';const res=await window.SonicAPI.login(targetUsername,targetPwd);localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));if(res.user&&res.user.must_change_password){setOldPassword(targetPwd);}onSuccess(res.user,res.access_token);}else if(activeTab==='register'){const res=await window.SonicAPI.register(username.trim(),email.trim(),password.trim());localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));onSuccess(res.user,res.access_token);}else if(activeTab==='force_change'){const res=await window.SonicAPI.changePassword(oldPassword.trim(),newPassword.trim());localStorage.setItem('sonic_token',res.access_token);const user=JSON.parse(localStorage.getItem('sonic_user')||'{}');user.must_change_password=false;localStorage.setItem('sonic_user',JSON.stringify(user));onSuccess(user,res.access_token);}}catch(err){setError(err.message||(activeTab==='login'?'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)':'Thao tác không thành công'));}finally{setLoading(false);}};const isForceMode=activeTab==='force_change';const canClose=!forceMandatory&&!isForceMode;return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-teal-400"},isForceMode?'⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo':activeTab==='login'?'🔐 Đăng Nhập Hệ Thống':'📝 Đăng Ký Tài Khoản'),canClose&&/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),error&&/*#__PURE__*/React.createElement("div",{className:"mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm"},error),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"mt-4 space-y-4"},isForceMode?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("p",{className:"text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed"},"🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu hiện tại (Mặc định: admin123)"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"current-password",required:true,value:oldPassword,onChange:e=>setOldPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu mới"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"new-password",required:true,value:newPassword,onChange:e=>setNewPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))):/*#__PURE__*/React.createElement(React.Fragment,null,activeTab==='login'?/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập ",/*#__PURE__*/React.createElement("span",{className:"text-teal-400 font-normal"},"(Tùy chọn - Admin có thể bỏ trống)")),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Mặc định: admin",value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})):/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập"),/*#__PURE__*/React.createElement("input",{type:"text",required:true,value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),activeTab==='register'&&/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Email"),/*#__PURE__*/React.createElement("input",{type:"email",required:true,value:email,onChange:e=>setEmail(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu ",activeTab==='login'&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-normal"},"(Lần đầu: admin123)")),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:activeTab==='login'?'current-password':'new-password',required:true,value:password,onChange:e=>setPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150"},loading?'Đang xác thực...':isForceMode?'Đổi Mật Khẩu Ngay':activeTab==='login'?'Đăng Nhập System':'Tạo Tài Khoản Mới'),activeTab==='login'&&!isForceMode&&/*#__PURE__*/React.createElement("button",{type:"button",onClick:()=>{setUsername('admin');setPassword('admin123');setError('');},className:"w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5"},"🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")),!isForceMode&&/*#__PURE__*/React.createElement("div",{className:"mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400"},activeTab==='login'?/*#__PURE__*/React.createElement("span",null,"Chưa có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('register'),className:"text-teal-400 hover:underline"},"Đăng ký ngay")):/*#__PURE__*/React.createElement("span",null,"Đã có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('login'),className:"text-teal-400 hover:underline"},"Đăng nhập")))));};const AIConfigModal=({isOpen,onClose,onConfigSaved})=>{if(!isOpen)return null;const defaultProvidersList=[{id:'openai_default',name:'OpenAI Official',provider_type:'openai',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o',temperature:0.7,is_active:true},{id:'openai_compat_default',name:'OpenAI Compatible (Ollama/LocalAI/DeepSeek)',provider_type:'openai_compatible',api_base_url:'http://localhost:11434/v1',api_key:'ollama',model_name:'deepseek-r1',temperature:0.7,is_active:false},{id:'anthropic_default',name:'Anthropic Claude',provider_type:'anthropic',api_base_url:'https://api.anthropic.com/v1',api_key:'',model_name:'claude-3-5-sonnet',temperature:0.7,is_active:false},{id:'gemini_default',name:'Google Gemini',provider_type:'gemini',api_base_url:'https://generativelanguage.googleapis.com',api_key:'',model_name:'gemini-1.5-pro',temperature:0.7,is_active:false}];const[providers,setProviders]=useState(defaultProvidersList);const[selectedId,setSelectedId]=useState('openai_default');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);useEffect(()=>{if(isOpen)loadConfigs();},[isOpen]);const loadConfigs=async()=>{setLoading(true);setError('');try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setProviders(data.providers);if(data.providers.length>0)setSelectedId(data.providers[0].id);}}catch(err){setError(err.message||'Lỗi nạp cấu hình AI');}finally{setLoading(false);}};const handleSave=async e=>{e.preventDefault();setMsg('');setError('');setLoading(true);try{const res=await window.SonicAPI.saveAIConfigs(providers);setMsg(res.message||'Đã lưu cấu hình AI Providers thành công!');if(onConfigSaved)onConfigSaved(providers);}catch(err){setError(err.message||'Lỗi khi lưu cấu hình AI');}finally{setLoading(false);}};const updateProviderField=(id,field,value)=>{setProviders(prev=>prev.map(p=>p.id===id?{...p,[field]:value}:p));};const activeProvider=providers.find(p=>p.id===selectedId)||providers[0];return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-cyan-400 flex items-center gap-2"},"🤖 Quản Lý & Cấu Hình AI Providers"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),msg&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{className:"mt-4 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"space-y-1.5 border-r border-[#383838] pr-3"},/*#__PURE__*/React.createElement("span",{className:"text-xs uppercase font-bold text-slate-400 block mb-2"},"Providers"),providers.map(p=>/*#__PURE__*/React.createElement("button",{key:p.id,tabIndex:0,onClick:()=>setSelectedId(p.id),onKeyDown:e=>{if(e.key===' '){e.preventDefault();updateProviderField(p.id,'is_active',!p.is_active);}},className:`w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId===p.id?'bg-cyan-600 text-white shadow':'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}`},/*#__PURE__*/React.createElement("span",{className:"truncate"},p.name),p.is_active&&/*#__PURE__*/React.createElement("span",{className:"w-2 h-2 rounded-full bg-emerald-400"}))),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();const newId='provider_'+Date.now();setProviders(prev=>[...prev,{id:newId,name:'New Provider',provider_type:'openai_compatible',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o-mini',temperature:0.7,is_active:false}]);setSelectedId(newId);},className:"flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded"},"+ Thêm"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(confirm(`Xóa provider "${providers.find(p=>p.id===selectedId)?.name}"?`)){setProviders(prev=>{const filtered=prev.filter(p=>p.id!==selectedId);if(filtered.length>0)setSelectedId(filtered[0].id);return filtered;});}},className:"px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded"},"Xóa"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;pi0){var tmp=provs[pos];provs[pos]=provs[pos-1];provs[pos-1]=tmp;setProviders(provs);}},className:"px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded",title:"Di chuyển lên"},"▲"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;piupdateProviderField(activeProvider.id,'name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Base URL (Endpoint)"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.api_base_url||'',onChange:e=>updateProviderField(activeProvider.id,'api_base_url',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500",placeholder:"https://api.openai.com/v1"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Key Cá Nhân"),/*#__PURE__*/React.createElement("input",{type:"password",value:activeProvider.api_key||'',onChange:e=>updateProviderField(activeProvider.id,'api_key',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500",placeholder:"sk-..."})),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Model Name"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.model_name||'',onChange:e=>updateProviderField(activeProvider.id,'model_name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Temperature"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.1",min:"0",max:"2",value:activeProvider.temperature??0.7,onChange:e=>updateProviderField(activeProvider.id,'temperature',parseFloat(e.target.value)),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"}))),/*#__PURE__*/React.createElement("div",{className:"pt-2 flex items-center justify-between"},/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-2 cursor-pointer text-xs text-slate-300"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:activeProvider.is_active,onChange:e=>updateProviderField(activeProvider.id,'is_active',e.target.checked),className:"rounded accent-cyan-500"}),"Kích hoạt Provider này"),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition"},loading?'Đang lưu...':'Lưu Cấu Hình AI'))))));};const ProfileModal=({isOpen,onClose,tracks,setTracks,setSelectedTrackId,projectName,setProjectName,currentProjectId,setCurrentProjectId,showToast})=>{if(!isOpen)return null;const[activeTab,setActiveTab]=useState('account');const[profile,setProfile]=useState(null);const[oldPassword,setOldPassword]=useState('');const[newPassword,setNewPassword]=useState('');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);const[dragOfs,setDragOfs]=useState({x:0,y:0});const dragRef=useRef({active:false,startX:0,startY:0,ofsX:0,ofsY:0});// Projects list state +const[projectsList,setProjectsList]=useState([]);const[loadingProjects,setLoadingProjects]=useState(false);// Files list state +const[filesList,setFilesList]=useState([]);const[loadingFiles,setLoadingFiles]=useState(false);// Confirmation modal state +const[confirmModal,setConfirmModal]=useState(null);const handleDragStart=e=>{const r=dragRef.current;r.active=true;r.startX=e.clientX;r.startY=e.clientY;r.ofsX=dragOfs.x;r.ofsY=dragOfs.y;const onMove=ev=>{if(!r.active)return;setDragOfs({x:r.ofsX+ev.clientX-r.startX,y:r.ofsY+ev.clientY-r.startY});};const onUp=()=>{r.active=false;document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};useEffect(()=>{if(isOpen){fetchProfile();if(activeTab==='projects')fetchProjects();if(activeTab==='files')fetchFiles();}},[isOpen,activeTab]);const fetchProfile=async()=>{try{const data=await window.SonicAPI.getProfile();setProfile(data);}catch(e){setError(e.message||'Không thể tải thông tin profile');}};const fetchProjects=async()=>{setLoadingProjects(true);try{const data=await window.SonicAPI.listCloudProjects();setProjectsList(data||[]);}catch(e){showToast(e.message||'Không thể tải danh sách dự án','error');}finally{setLoadingProjects(false);}};const fetchFiles=async()=>{setLoadingFiles(true);try{const activeFileIds=tracks.map(t=>t.serverFileId).filter(Boolean);const data=await window.SonicAPI.listMyFiles(activeFileIds);setFilesList(data||[]);}catch(e){showToast(e.message||'Không thể tải danh sách tệp tin','error');}finally{setLoadingFiles(false);}};const handleOpenProject=async projectId=>{setAppWarningModal({title:"Mở dự án",message:"Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.",isAlert:false,onConfirm:async()=>{try{const proj=await window.SonicAPI.getCloudProject(projectId);const parsed=JSON.parse(proj.data_json);let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];if(parsed.main_session){const result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;}else{restoredTracks=(parsed.tracks||[]).map(t=>({...t,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null}));}setTracks(restoredTracks);setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(proj.name);setCurrentProjectId(proj.id);if(restoredSessionTabs.length>0){setSessionTabs(restoredSessionTabs);}localStorage.setItem('sonic_project_name',proj.name);localStorage.setItem('sonic_project_id',proj.id);showToast(`Đã nạp dự án "${proj.name}" thành công!`,"success");}catch(e){showToast(e.message||"Lỗi khi nạp dự án","error");}}});};const handleDeleteProject=async(projectId,e)=>{e.stopPropagation();setConfirmModal({title:"Xóa dự án Cloud",message:"Bạn có chắc chắn muốn xóa dự án này khỏi Cloud? Hành động này không thể hoàn tác.",onConfirm:async()=>{try{await window.SonicAPI.deleteCloudProject(projectId);showToast("Đã xóa dự án thành công!","success");fetchProjects();fetchProfile();}catch(err){showToast(err.message||"Lỗi khi xóa dự án","error");}}});};const handleDeleteFile=async fileId=>{if(!confirm(`Bạn có chắc chắn muốn xóa tệp tin ${fileId}?`))return;try{await window.SonicAPI.deleteMyFile(fileId);showToast("Đã xóa tệp tin thành công!","success");fetchFiles();fetchProfile();}catch(err){showToast(err.message||"Lỗi khi xóa tệp tin","error");}};const handleCleanUnusedFiles=async()=>{const unusedFiles=filesList.filter(f=>!f.is_in_use);if(unusedFiles.length===0){showToast("Không có tập tin rác nào để dọn dẹp.","info");return;}if(!confirm(`Bạn có chắc chắn muốn xóa tất cả ${unusedFiles.length} tập tin rác để giải phóng dung lượng?`))return;let successCount=0;for(const file of unusedFiles){try{await window.SonicAPI.deleteMyFile(file.file_id);successCount++;}catch(e){console.error("Lỗi xóa file rác: ",file.file_id,e);}}showToast(`Đã dọn dẹp thành công ${successCount}/${unusedFiles.length} tập tin rác!`,"success");fetchFiles();fetchProfile();};const handleChangePassword=async e=>{e.preventDefault();setMsg('');setError('');setLoading(true);try{const res=await window.SonicAPI.changePassword(oldPassword,newPassword);setMsg(res.message||'Đổi mật khẩu thành công!');setOldPassword('');setNewPassword('');}catch(err){setError(err.message||'Lỗi khi đổi mật khẩu');}finally{setLoading(false);}};const modalStyle={left:`calc(50% + ${dragOfs.x}px)`,top:`calc(50% + ${dragOfs.y}px)`,transform:'translate(-50%, -50%)'};return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"backdrop",className:"fixed inset-0 z-40 bg-black/70 backdrop-blur-sm",onClick:onClose}),confirmModal&&/*#__PURE__*/React.createElement("div",{key:"confirm-overlay",className:"fixed inset-0 z-[60] flex items-center justify-center bg-black/50"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-lg shadow-2xl p-5 max-w-sm w-full text-slate-200"},/*#__PURE__*/React.createElement("h4",{className:"text-sm font-bold text-rose-400 mb-2"},confirmModal.title),/*#__PURE__*/React.createElement("p",{className:"text-xs text-slate-300 mb-4"},confirmModal.message),/*#__PURE__*/React.createElement("div",{className:"flex justify-end gap-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setConfirmModal(null),className:"px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold"},"Hủy"),/*#__PURE__*/React.createElement("button",{onClick:()=>{const fn=confirmModal.onConfirm;setConfirmModal(null);fn();},className:"px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold"},"Xác nhận xóa")))),/*#__PURE__*/React.createElement("div",{key:"dialog",className:"fixed z-50 bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200 flex flex-col max-h-[85vh]",style:modalStyle},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-3 border-b border-[#383838] shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:handleDragStart},/*#__PURE__*/React.createElement("h3",{className:"text-md font-bold text-teal-400 flex items-center gap-1.5"},"👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),/*#__PURE__*/React.createElement("div",{className:"flex gap-2 border-b border-[#383838] py-2 shrink-0 text-xs font-semibold"},[/*#__PURE__*/React.createElement("button",{key:"tab-acc",onClick:()=>setActiveTab('account'),className:`px-3 py-1.5 rounded transition ${activeTab==='account'?'bg-teal-950/60 text-teal-300 border border-teal-800':'text-slate-400 hover:text-slate-200'}`},"Tài Khoản"),/*#__PURE__*/React.createElement("button",{key:"tab-proj",onClick:()=>setActiveTab('projects'),className:`px-3 py-1.5 rounded transition ${activeTab==='projects'?'bg-teal-950/60 text-teal-300 border border-teal-800':'text-slate-400 hover:text-slate-200'}`},"Dự Án Cloud"),/*#__PURE__*/React.createElement("button",{key:"tab-files",onClick:()=>setActiveTab('files'),className:`px-3 py-1.5 rounded transition ${activeTab==='files'?'bg-teal-950/60 text-teal-300 border border-teal-800':'text-slate-400 hover:text-slate-200'}`},"Tập Tin Của Tôi")]),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto pt-4 space-y-4 pr-1 min-h-[300px]"},activeTab==='account'&&profile?[/*#__PURE__*/React.createElement("div",{key:"quota-info",className:"bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs"},[/*#__PURE__*/React.createElement("div",{key:"username"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Tên người dùng"),/*#__PURE__*/React.createElement("span",{className:"font-bold text-teal-300 text-sm"},profile.username)]),/*#__PURE__*/React.createElement("div",{key:"role"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Vai trò"),/*#__PURE__*/React.createElement("span",{className:"uppercase font-semibold text-amber-400"},profile.role)]),/*#__PURE__*/React.createElement("div",{key:"email"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Email"),/*#__PURE__*/React.createElement("span",null,profile.email)]),/*#__PURE__*/React.createElement("div",{key:"quota"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-500 block"},"Dung lượng Quota"),/*#__PURE__*/React.createElement("span",{className:"font-semibold text-slate-200"},`${profile.quota.used_mb} MB / ${profile.quota.storage_limit_mb} MB`)])]),/*#__PURE__*/React.createElement("div",{key:"progress"},[/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-xs mb-1"},[/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Tiến trình sử dụng bộ nhớ Server"),/*#__PURE__*/React.createElement("span",{className:"font-bold text-teal-400"},`${(profile.quota.used_mb/profile.quota.storage_limit_mb*100).toFixed(1)}%`)]),/*#__PURE__*/React.createElement("div",{className:"w-full h-2 bg-slate-800 rounded-full overflow-hidden"},[/*#__PURE__*/React.createElement("div",{className:"h-full bg-teal-500 rounded-full transition-all duration-300",style:{width:`${Math.min(100,profile.quota.used_mb/profile.quota.storage_limit_mb*100)}%`}})])]),/*#__PURE__*/React.createElement("form",{key:"pwd-form",onSubmit:handleChangePassword,className:"pt-4 border-t border-[#383838] space-y-3"},[/*#__PURE__*/React.createElement("h4",{className:"text-xs font-bold text-slate-300 uppercase"},"Thay Đổi Mật Khẩu"),msg&&/*#__PURE__*/React.createElement("div",{className:"p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{key:"old"},[/*#__PURE__*/React.createElement("label",{className:"block text-xs text-slate-400 mb-1"},"Mật khẩu cũ"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"current-password",required:true,value:oldPassword,onChange:e=>setOldPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"})]),/*#__PURE__*/React.createElement("div",{key:"new"},[/*#__PURE__*/React.createElement("label",{className:"block text-xs text-slate-400 mb-1"},"Mật khẩu mới"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"new-password",required:true,value:newPassword,onChange:e=>setNewPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"})]),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition"},loading?'Đang cập nhật...':'Cập Nhật Mật Khẩu')])]:activeTab==='projects'?[loadingProjects?/*#__PURE__*/React.createElement("div",{key:"loading",className:"text-center py-8 text-xs text-zinc-500"},"Đang tải danh sách dự án..."):projectsList.length===0?/*#__PURE__*/React.createElement("div",{key:"empty",className:"text-center py-8 text-xs text-zinc-500"},"Bạn chưa có dự án nào lưu trên Cloud."):/*#__PURE__*/React.createElement("div",{key:"list",className:"space-y-1.5"},[projectsList.map(proj=>/*#__PURE__*/React.createElement("div",{key:proj.id,onClick:()=>handleOpenProject(proj.id),className:"flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"},[/*#__PURE__*/React.createElement("div",{key:"meta"},[/*#__PURE__*/React.createElement("div",{className:"font-bold text-slate-200 group-hover:text-teal-400"},proj.name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 mt-0.5"},`Dung lượng: ${proj.size_mb} MB | Cập nhật: ${new Date(proj.updated_at*1000).toLocaleString()}`)]),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex items-center gap-1.5"},[/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();handleOpenProject(proj.id);},className:"px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"},"MỞ"),/*#__PURE__*/React.createElement("button",{onClick:e=>handleDeleteProject(proj.id,e),className:"px-2 py-1 bg-rose-950/60 hover:bg-rose-900/80 text-rose-300 rounded font-semibold text-[10px]"},"XÓA")])]))])]:activeTab==='files'?[/*#__PURE__*/React.createElement("div",{key:"cleanup-header",className:"flex items-center justify-between bg-zinc-900/60 p-2.5 rounded border border-zinc-800 text-xs shrink-0 mb-3"},[/*#__PURE__*/React.createElement("span",{className:"text-zinc-400 text-[10px]"},"💡 Các tập tin không dùng trong dự án nào sẽ được đánh dấu 'Không dùng' và có thể xóa để giải phóng dung lượng."),/*#__PURE__*/React.createElement("button",{onClick:handleCleanUnusedFiles,className:"px-2.5 py-1 bg-rose-700 hover:bg-rose-600 text-white rounded font-bold text-[10px] uppercase flex items-center gap-1"},"🧹 Dọn dẹp tệp rác")]),loadingFiles?/*#__PURE__*/React.createElement("div",{key:"loading",className:"text-center py-8 text-xs text-zinc-500"},"Đang tải danh sách tập tin..."):filesList.length===0?/*#__PURE__*/React.createElement("div",{key:"empty",className:"text-center py-8 text-xs text-zinc-500"},"Chưa có tập tin nào tải lên hoặc tạo ra."):/*#__PURE__*/React.createElement("div",{key:"list",className:"space-y-1.5"},[filesList.map(file=>/*#__PURE__*/React.createElement("div",{key:file.file_id,className:"flex items-center justify-between p-3 bg-[#1e1e1e] rounded border border-zinc-800 text-xs"},[/*#__PURE__*/React.createElement("div",{key:"meta",className:"max-w-[70%]"},[/*#__PURE__*/React.createElement("div",{className:"font-semibold text-slate-300 truncate"},file.original_name||file.file_id),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-zinc-500 mt-0.5"},`Loại: ${file.type} | Dung lượng: ${file.size_mb} MB | Tạo lúc: ${new Date(file.created_at*1000).toLocaleString()}`)]),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex items-center gap-2"},[file.is_in_use?/*#__PURE__*/React.createElement("span",{className:"px-2 py-0.5 bg-emerald-950 text-emerald-400 border border-emerald-900 rounded text-[9px] uppercase font-bold font-mono"},"Đang dùng"):/*#__PURE__*/React.createElement("span",{className:"px-2 py-0.5 bg-zinc-850 text-zinc-400 border border-zinc-700 rounded text-[9px] uppercase font-bold font-mono"},"Không dùng"),!file.is_in_use&&/*#__PURE__*/React.createElement("button",{onClick:()=>handleDeleteFile(file.file_id),className:"px-2 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded font-semibold text-[10px] border border-rose-900"},"Xóa")])]))])]:null)));};const SaveProjectModal=({isOpen,onClose,onSave})=>{if(!isOpen)return null;const[name,setName]=useState('');const handleSubmit=e=>{e.preventDefault();if(!name.trim())return;onSave(name.trim());onClose();};return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-teal-400 mb-4 uppercase"},"Đặt tên dự án"),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"space-y-4"},[/*#__PURE__*/React.createElement("input",{key:"name-input",type:"text",placeholder:"Nhập tên dự án...",required:true,value:name,onChange:e=>setName(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",autoFocus:true}),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex justify-end gap-2 text-xs"},[/*#__PURE__*/React.createElement("button",{key:"cancel",type:"button",onClick:onClose,className:"px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"},"Hủy"),/*#__PURE__*/React.createElement("button",{key:"save",type:"submit",className:"px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"},"Lưu")])])));};const SaveAsModal=({isOpen,onClose,projectName,onSaveCloud,onSaveLocal})=>{if(!isOpen)return null;const[name,setName]=useState(projectName||'');const[saveType,setSaveType]=useState('cloud');const handleSubmit=e=>{e.preventDefault();if(!name.trim())return;if(saveType==='cloud'){onSaveCloud(name.trim());}else{onSaveLocal(name.trim());}onClose();};return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"},/*#__PURE__*/React.createElement("h3",{className:"text-sm font-bold text-teal-400 mb-4 uppercase"},"Lưu dưới tên khác (Save As...)"),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"space-y-4"},[/*#__PURE__*/React.createElement("div",{key:"name-block"},[/*#__PURE__*/React.createElement("label",{className:"block text-[10px] text-zinc-400 uppercase font-bold mb-1"},"Tên dự án mới"),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Nhập tên mới...",required:true,value:name,onChange:e=>setName(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",autoFocus:true})]),/*#__PURE__*/React.createElement("div",{key:"type-block"},[/*#__PURE__*/React.createElement("label",{className:"block text-[10px] text-zinc-400 uppercase font-bold mb-1.5"},"Phương thức lưu trữ"),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs"},[/*#__PURE__*/React.createElement("button",{key:"btn-cloud",type:"button",onClick:()=>setSaveType('cloud'),className:`py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType==='cloud'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`},[/*#__PURE__*/React.createElement("span",{key:"title"},"☁️ Lưu Cloud"),/*#__PURE__*/React.createElement("span",{key:"desc",className:"text-[9px] font-normal text-zinc-500"},"Lưu lên server cá nhân")]),/*#__PURE__*/React.createElement("button",{key:"btn-local",type:"button",onClick:()=>setSaveType('local'),className:`py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType==='local'?'bg-teal-950/60 border-teal-500 text-teal-300':'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`},[/*#__PURE__*/React.createElement("span",{key:"title"},"💾 Tải về máy (.sfs)"),/*#__PURE__*/React.createElement("span",{key:"desc",className:"text-[9px] font-normal text-zinc-500"},"Tải tệp JSON dự án về máy")])])]),/*#__PURE__*/React.createElement("div",{key:"actions",className:"flex justify-end gap-2 text-xs pt-2"},[/*#__PURE__*/React.createElement("button",{key:"cancel",type:"button",onClick:onClose,className:"px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"},"Hủy"),/*#__PURE__*/React.createElement("button",{key:"save",type:"submit",className:"px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"},"Thực hiện lưu")])])));};const SystemManagerModal=({isOpen,onClose})=>{if(!isOpen)return null;const[users,setUsers]=useState([]);const[loading,setLoading]=useState(true);const[msg,setMsg]=useState('');const[error,setError]=useState('');const[editingQuotaUser,setEditingQuotaUser]=useState(null);const[newQuotaMb,setNewQuotaMb]=useState(500);useEffect(()=>{if(isOpen)loadUsers();},[isOpen]);const loadUsers=async()=>{setLoading(true);setError('');try{const data=await window.SonicAPI.listUsers();setUsers(data);}catch(err){setError(err.message||'Không thể tải danh sách người dùng hệ thống');}finally{setLoading(false);}};const handleSaveQuota=async userId=>{try{await window.SonicAPI.updateUserQuota(userId,parseInt(newQuotaMb));setMsg('Đã cập nhật hạn mức Quota thành công!');setEditingQuotaUser(null);loadUsers();}catch(err){setError(err.message||'Lỗi cập nhật Quota');}};const handleToggleRole=async user=>{const nextRole=user.role==='admin'?'standard':'admin';try{await window.SonicAPI.updateUserRole(user.id,nextRole,user.is_active);setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`);loadUsers();}catch(err){setError(err.message||'Lỗi cập nhật vai trò');}};const handleDeleteUser=async userId=>{if(!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?'))return;try{await window.SonicAPI.deleteUser(userId);setMsg('Đã xóa người dùng thành công');loadUsers();}catch(err){setError(err.message||'Lỗi khi xóa người dùng');}};return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-amber-400"},"⚙️ Quản Lý Hệ Thống & Phân Quyền Admin"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),msg&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{className:"mt-4 overflow-x-auto max-h-96 no-scrollbar"},loading?/*#__PURE__*/React.createElement("div",{className:"py-8 text-center text-slate-400 text-xs"},"Đang tải thông tin hệ thống..."):/*#__PURE__*/React.createElement("table",{className:"w-full text-left text-xs border-collapse"},/*#__PURE__*/React.createElement("thead",null,/*#__PURE__*/React.createElement("tr",{className:"border-b border-[#383838] text-slate-400 bg-[#1e1e1e]"},/*#__PURE__*/React.createElement("th",{className:"p-3"},"Tên Người Dùng"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Email"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Vai Trò"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Dung Lượng Sử Dụng"),/*#__PURE__*/React.createElement("th",{className:"p-3"},"Hạn Mức Quota"),/*#__PURE__*/React.createElement("th",{className:"p-3 text-right"},"Thao Tác"))),/*#__PURE__*/React.createElement("tbody",{className:"divide-y divide-[#333]"},users.map(u=>/*#__PURE__*/React.createElement("tr",{key:u.id,className:"hover:bg-[#2e2e2e]"},/*#__PURE__*/React.createElement("td",{className:"p-3 font-semibold text-teal-300"},u.username,u.must_change_password&&/*#__PURE__*/React.createElement("span",{className:"ml-2 text-xs bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded"},"Mật khẩu gốc")),/*#__PURE__*/React.createElement("td",{className:"p-3 text-slate-300"},u.email),/*#__PURE__*/React.createElement("td",{className:"p-3 uppercase font-bold text-amber-400"},u.role),/*#__PURE__*/React.createElement("td",{className:"p-3"},u.used_mb," MB"),/*#__PURE__*/React.createElement("td",{className:"p-3"},editingQuotaUser===u.id?/*#__PURE__*/React.createElement("div",{className:"flex items-center space-x-1"},/*#__PURE__*/React.createElement("input",{type:"number",value:newQuotaMb,onChange:e=>setNewQuotaMb(e.target.value),className:"w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200"}),/*#__PURE__*/React.createElement("span",null,"MB"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleSaveQuota(u.id),className:"px-2 py-0.5 bg-teal-600 rounded text-xs"},"Lưu")):/*#__PURE__*/React.createElement("span",{className:"font-semibold"},u.quota_mb," MB")),/*#__PURE__*/React.createElement("td",{className:"p-3 text-right space-x-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setEditingQuotaUser(u.id);setNewQuotaMb(u.quota_mb);},className:"px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs"},"Sửa Quota"),/*#__PURE__*/React.createElement("button",{onClick:()=>handleToggleRole(u),className:"px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-xs"},"Đổi Role"),u.role!=='admin'&&/*#__PURE__*/React.createElement("button",{onClick:()=>handleDeleteUser(u.id),className:"px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs"},"Xóa")))))))));};const PianoRollTabEditor=({st,zoom,bpm,viewportWidth,onClose,onUpdateNotes})=>{const[activeRollTool,setActiveRollTool]=React.useState('select');const[snapVal,setSnapVal]=React.useState('1/16');const[ccMode,setCcMode]=React.useState('velocity');const[rollZoom,setRollZoom]=React.useState(60);// local horizontal zoom factor +const canvasRef=React.useRef(null);const ccCanvasRef=React.useRef(null);const gridScrollRef=React.useRef(null);const keybedRef=React.useRef(null);const NoteHeight=18;const pixelsPerBeat=rollZoom;const timeSigNum=4;const totalBeats=(st.duration||4)*timeSigNum;const drawWidth=totalBeats*pixelsPerBeat;const[notes,setNotes]=React.useState(st.notes||[]);const[draggedNote,setDraggedNote]=React.useState(null);// { mode: 'move'|'resize', idx, startOffsetBeat, originalStart } +const[hoveredResizeIdx,setHoveredResizeIdx]=React.useState(-1);React.useEffect(()=>{onUpdateNotes(st.id,notes);},[notes]);const getSnapBeat=(beat,mode)=>{let q=0.25;if(mode==='free')return beat;if(mode==='1')q=4.0;else if(mode==='1/2')q=2.0;else if(mode==='1/4')q=1.0;else if(mode==='1/8')q=0.5;else if(mode==='1/16')q=0.25;else if(mode==='1/32')q=0.125;return Math.round(beat/q)*q;};const getSnapDuration=mode=>{if(mode==='free')return 0.25;if(mode==='1')return 4.0;if(mode==='1/2')return 2.0;if(mode==='1/4')return 1.0;if(mode==='1/8')return 0.5;if(mode==='1/16')return 0.25;if(mode==='1/32')return 0.125;return 0.25;};// Local Zoom Wheel Event handler to block browser page zoom +React.useEffect(()=>{const handleWheelRaw=e=>{if(e.ctrlKey){e.preventDefault();const zoomFactor=e.deltaY<0?1.15:0.85;setRollZoom(prev=>Math.max(15,Math.min(250,prev*zoomFactor)));}};const container=gridScrollRef.current;if(container){container.addEventListener('wheel',handleWheelRaw,{passive:false});}return()=>{if(container){container.removeEventListener('wheel',handleWheelRaw);}};},[]);React.useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=128*NoteHeight;canvas.width=drawWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);// Draw background rows +for(let pitch=0;pitch<128;pitch++){const y=(127-pitch)*NoteHeight;const isBlack=[1,3,6,8,10].includes(pitch%12);ctx.fillStyle=isBlack?'#1a1a1e':'#25252a';ctx.fillRect(0,y,drawWidth,NoteHeight);ctx.strokeStyle='#2d2d35';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(0,y+NoteHeight);ctx.lineTo(drawWidth,y+NoteHeight);ctx.stroke();}// Draw snap lines +let snapBeats=0.25;if(snapVal==='1')snapBeats=4.0;else if(snapVal==='1/2')snapBeats=2.0;else if(snapVal==='1/4')snapBeats=1.0;else if(snapVal==='1/8')snapBeats=0.5;else if(snapVal==='1/16')snapBeats=0.25;else if(snapVal==='1/32')snapBeats=0.125;for(let beat=0;beat<=totalBeats;beat+=snapBeats){const x=beat*pixelsPerBeat;const isBar=beat%timeSigNum===0;ctx.strokeStyle=isBar?'#444450':'#2d2d35';ctx.lineWidth=isBar?1.2:0.6;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke();}// Draw notes with velocity layer representation (Hiển thị layer velocity ngay trên nốt) +notes.forEach(note=>{const x=note.start_beat*pixelsPerBeat;const y=(127-note.pitch)*NoteHeight;const w=note.duration_beats*pixelsPerBeat;// Draw background of note (lighter yellow) +ctx.fillStyle='rgba(234, 179, 8, 0.25)';ctx.strokeStyle='#ca8a04';ctx.lineWidth=1;ctx.fillRect(x+1,y+1,w-2,NoteHeight-2);ctx.strokeRect(x+1,y+1,w-2,NoteHeight-2);// Draw velocity layer (solid yellow bar inside, proportional to velocity) +const vel=note.velocity!==undefined?note.velocity:0.8;const velW=Math.max(2,(w-2)*vel);ctx.fillStyle='#eab308';ctx.fillRect(x+1,y+1,velW,NoteHeight-2);});},[notes,snapVal,rollZoom]);React.useEffect(()=>{const canvas=ccCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=80;canvas.width=drawWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);ctx.fillStyle='#161616';ctx.fillRect(0,0,drawWidth,h);ctx.strokeStyle='#252525';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h/2);ctx.lineTo(drawWidth,h/2);ctx.stroke();notes.forEach(note=>{const x=note.start_beat*pixelsPerBeat+note.duration_beats*pixelsPerBeat/2;let val=note.velocity!==undefined?note.velocity:0.8;if(ccMode==='pan'){val=(note.pan!==undefined?note.pan:0.0)*0.5+0.5;}const stemH=val*(h-20)+10;const y=h-stemH;ctx.strokeStyle=ccMode==='pan'?'#a78bfa':'#fbbf24';ctx.lineWidth=2.5;ctx.beginPath();ctx.moveTo(x,h);ctx.lineTo(x,y);ctx.stroke();ctx.fillStyle=ccMode==='pan'?'#c084fc':'#fbbf24';ctx.beginPath();ctx.arc(x,y,3.5,0,2*Math.PI);ctx.fill();});},[notes,ccMode,rollZoom]);React.useEffect(()=>{if(gridScrollRef.current){gridScrollRef.current.scrollTop=(127-60)*NoteHeight-150;}},[]);const handleGridMouseDown=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat;const pitch=127-Math.floor(y/NoteHeight);if(hoveredResizeIdx!==-1){setDraggedNote({mode:'resize',idx:hoveredResizeIdx,originalStart:notes[hoveredResizeIdx].start_beat});return;}const clickedNoteIdx=notes.findIndex(n=>{return pitch===n.pitch&&beat>=n.start_beat&&beat[...prev,newNote]);}}else if(activeRollTool==='eraser'){if(clickedNoteIdx!==-1){setNotes(prev=>prev.filter((_,idx)=>idx!==clickedNoteIdx));}}else if(activeRollTool==='pen'){if(clickedNoteIdx===-1){const start=getSnapBeat(beat,snapVal);const newNote={id:'note_'+Date.now()+Math.random().toString(36).substr(2,5),pitch:pitch,start_beat:start,duration_beats:getSnapDuration(snapVal),velocity:0.8,pan:0.0};setNotes(prev=>[...prev,newNote]);}}};const handleGridMouseMove=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat;const pitch=127-Math.floor(y/NoteHeight);if(!draggedNote){// Check if mouse is near the right edge of any note +let foundIdx=-1;for(let i=0;i=n.start_beat){foundIdx=i;break;}}}if(foundIdx!==-1){canvas.style.cursor='ew-resize';setHoveredResizeIdx(foundIdx);}else{canvas.style.cursor=activeRollTool==='eraser'?'pointer':'crosshair';setHoveredResizeIdx(-1);}return;}if(draggedNote.mode==='resize'){const newDuration=getSnapBeat(Math.max(0.125,beat-draggedNote.originalStart),snapVal);setNotes(prev=>prev.map((n,idx)=>{if(idx!==draggedNote.idx)return n;return{...n,duration_beats:newDuration};}));}else{const newStart=getSnapBeat(Math.max(0,beat-draggedNote.startOffsetBeat),snapVal);const newPitch=Math.max(0,Math.min(127,pitch));setNotes(prev=>prev.map((n,idx)=>{if(idx!==draggedNote.idx)return n;return{...n,start_beat:newStart,pitch:newPitch};}));}};const handleGridMouseUp=()=>{setDraggedNote(null);};const handleCCMouseDown=e=>{const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat;// Find note near beat or closest note horizontally +let noteIdx=notes.findIndex(n=>beat>=n.start_beat&&beat<=n.start_beat+n.duration_beats);if(noteIdx===-1){let minDistance=Infinity;notes.forEach((n,idx)=>{const center=n.start_beat+n.duration_beats/2;const dist=Math.abs(center-beat);if(distprev.map((n,idx)=>{if(idx!==noteIdx)return n;if(ccMode==='pan'){return{...n,pan:(val-0.5)*2.0};}else{return{...n,velocity:val};}}));}};const renderKeybed=()=>{const keys=[];for(let pitch=127;pitch>=0;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition ${isBlack?'bg-zinc-950 text-slate-500 border-zinc-900':'bg-white text-zinc-800 border-r border-zinc-400'}`},showLabel&&label));}return keys;};const handleScroll=e=>{if(keybedRef.current){keybedRef.current.scrollTop=e.currentTarget.scrollTop;}};return/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col"},/*#__PURE__*/React.createElement("div",{className:"h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-yellow-500 uppercase flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3.5 h-3.5"}),st.label),/*#__PURE__*/React.createElement("div",{className:"flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs"},['select','pen','eraser'].map(tool=>/*#__PURE__*/React.createElement("button",{key:tool,onClick:()=>setActiveRollTool(tool),className:`px-2.5 py-1 rounded capitalize ${activeRollTool===tool?'bg-yellow-600 text-white font-bold':'text-zinc-400 hover:text-zinc-200'}`},tool))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 text-xs"},/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 font-semibold"},"Snap:"),/*#__PURE__*/React.createElement("select",{value:snapVal,onChange:e=>setSnapVal(e.target.value),className:"bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500"},['free','1','1/2','1/4','1/8','1/16','1/32'].map(v=>/*#__PURE__*/React.createElement("option",{key:v,value:v},v)))),/*#__PURE__*/React.createElement("div",{className:"flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs"},['velocity','pan'].map(mode=>/*#__PURE__*/React.createElement("button",{key:mode,onClick:()=>setCcMode(mode),className:`px-2.5 py-1 rounded capitalize ${ccMode===mode?'bg-purple-900/60 text-purple-300 font-bold border border-purple-700':'text-zinc-400 hover:text-zinc-200'}`},mode)))),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}),"Đóng")),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 relative"},/*#__PURE__*/React.createElement("div",{ref:keybedRef,className:"w-[60px] overflow-hidden flex flex-col border-r border-zinc-900 shrink-0",style:{scrollbarWidth:'none',msOverflowStyle:'none'}},renderKeybed()),/*#__PURE__*/React.createElement("div",{ref:gridScrollRef,onScroll:handleScroll,className:"flex-1 overflow-auto bg-[#141414] min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${drawWidth}px`,height:`${128*NoteHeight}px`},className:"relative"},/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,onMouseDown:handleGridMouseDown,onMouseMove:handleGridMouseMove,onMouseUp:handleGridMouseUp,onMouseLeave:handleGridMouseUp,className:"absolute inset-0 cursor-crosshair"})))),/*#__PURE__*/React.createElement("div",{className:"h-20 bg-[#161616] border-t border-zinc-900 flex shrink-0"},/*#__PURE__*/React.createElement("div",{className:"w-[60px] bg-[#222222] border-r border-zinc-900 flex items-center justify-center text-[10px] text-zinc-500 font-mono shrink-0 font-bold"},ccMode.toUpperCase()),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-x-hidden min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${drawWidth}px`,height:'100%'},className:"relative"},/*#__PURE__*/React.createElement("canvas",{ref:ccCanvasRef,onMouseDown:handleCCMouseDown,className:"absolute inset-0 cursor-ns-resize"})))));};const serializeProjectToSchema=(projectId,name,bpmVal,tracksList,subTabsList,sessionTabsList)=>{const secondsPerBar=60.0/parseFloat(bpmVal||120)*4;const mainTracks=(tracksList||[]).map(t=>{let trackType="AUDIO";if(t.sections&&t.sections.length>0)trackType="SECTION";else if(t.midiItems&&t.midiItems.length>0)trackType="MIDI";const items=[];if(trackType==="AUDIO"&&t.clips){t.clips.forEach(c=>{const durationSec=c.buffer?c.buffer.duration:4.0;items.push({id:c.id,name:c.name||"Audio Clip",type:"AUDIO_ITEM",start_bar:c.startTime/secondsPerBar,duration_bars:durationSec/(c.speed||1.0)/secondsPerBar,clip_start_offset_bars:0.0,source_data:{audio_file_url:t.serverFileId?`/static/audio/uploads/${t.serverFileId}`:"",sample_rate:c.buffer?c.buffer.sampleRate:44100,channels:c.buffer?c.buffer.numberOfChannels:2,gain:1.0}});});}else if(trackType==="MIDI"&&t.midiItems){t.midiItems.forEach(m=>{items.push({id:m.id,name:m.name||"MIDI Item",type:"MIDI_ITEM",start_bar:m.startTime/secondsPerBar,duration_bars:m.duration||4.0,clip_start_offset_bars:0.0,source_data:{total_buffer_bars:m.duration||8.0,notes:(m.notes||[]).map(n=>({id:n.id||'note_'+Math.random().toString(36).substr(2,9),pitch:n.pitch||60,start_beat:n.start_beat||0.0,duration_beats:n.duration_beats||1.0,velocity:n.velocity||0.8,pan:n.pan||0.0}))}});});}else if(trackType==="SECTION"&&t.sections){t.sections.forEach(s=>{items.push({id:s.id,name:s.name||"Section Item",type:"SECTION_ITEM",start_bar:s.start/secondsPerBar,duration_bars:s.duration/secondsPerBar,clip_start_offset_bars:0.0,source_data:{referenced_section_id:s.sectionId||s.id}});});}return{id:t.id,name:t.name,type:trackType,volume_db:t.volumeDb||0.0,pan:t.pan||0.0,mute:t.muted||false,solo:t.solo||false,items:items};});const sectionStore={};(sessionTabsList||[]).forEach(st=>{const secTracks=(st.tracks||[]).map(t=>{let trackType="AUDIO";if(t.sections&&t.sections.length>0)trackType="SECTION";else if(t.midiItems&&t.midiItems.length>0)trackType="MIDI";return{id:t.id,name:t.name,type:trackType,volume_db:t.volumeDb||0.0,pan:t.pan||0.0,mute:t.muted||false,solo:t.solo||false,items:[]};});sectionStore[st.sectionId]={id:st.sectionId,name:st.name,is_root:false,length_bars:st.length_bars||16.0,auto_compute_length:st.auto_compute_length!==undefined?st.auto_compute_length:true,tracks:secTracks};});return{project_id:projectId||'proj_'+Date.now(),metadata:{title:name||"Dự án mới",bpm:parseFloat(bpmVal||120),time_signature_numerator:4,time_signature_denominator:4,sample_rate:44100},main_session:{id:"main",name:"MAIN SESSION",is_root:true,length_bars:16.0,auto_compute_length:true,tracks:mainTracks},section_store:sectionStore};};const deserializeProjectFromSchema=schemaObj=>{const bpmVal=schemaObj.metadata?schemaObj.metadata.bpm:120;const secondsPerBar=60.0/bpmVal*4;const restoredTracks=(schemaObj.main_session.tracks||[]).map(t=>{const clips=[];const sections=[];const midiItems=[];(t.items||[]).forEach(item=>{if(item.type==="AUDIO_ITEM"){const src=item.source_data||{};const audioUrl=src.audio_file_url||"";const serverFileId=audioUrl.split("/").pop()||null;clips.push({id:item.id,name:item.name,startTime:item.start_bar*secondsPerBar,speed:1.0});}else if(item.type==="MIDI_ITEM"){const src=item.source_data||{};midiItems.push({id:item.id,name:item.name,startTime:item.start_bar*secondsPerBar,duration:item.duration_bars,notes:src.notes||[]});}else if(item.type==="SECTION_ITEM"){const src=item.source_data||{};sections.push({id:item.id,name:item.name,start:item.start_bar*secondsPerBar,duration:item.duration_bars*secondsPerBar,sectionId:src.referenced_section_id});}});return{id:t.id,name:t.name,volumeDb:t.volume_db||0.0,pan:t.pan||0.0,muted:t.mute||false,solo:t.solo||false,color:t.id==='1'?'#0f766e':'#1d4ed8',markers:[],serverFileId:t.items&&t.items.find(i=>i.type==="AUDIO_ITEM")?.source_data?.audio_file_url?.split("/").pop()||null,clips:clips,sections:sections,midiItems:midiItems};});const restoredSessionTabs=[];const sectionStore=schemaObj.section_store||{};Object.keys(sectionStore).forEach(secId=>{const secContainer=sectionStore[secId];const secTracks=(secContainer.tracks||[]).map(t=>{return{id:t.id,name:t.name,volumeDb:t.volume_db||0.0,pan:t.pan||0.0,muted:t.mute||false,solo:t.solo||false,clips:[],sections:[],midiItems:[]};});restoredSessionTabs.push({id:'session_'+secId,name:secContainer.name,sectionId:secId,tracks:secTracks,length_bars:secContainer.length_bars||16.0,auto_compute_length:secContainer.auto_compute_length});});return{bpm:bpmVal,tracks:restoredTracks,sessionTabs:restoredSessionTabs};};const App=()=>{// ── State Definitions ── +const[tracks,setTracks]=useState([{id:'1',name:'Track 01',buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:'#0f766e',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]},{id:'2',name:'Track 02',buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:'#1d4ed8',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]}]);const[appWarningModal,setAppWarningModal]=useState(null);const[bpm,setBpm]=useState(localStorage.getItem('studio_bpm')||'120');const[draggedClip,setDraggedClip]=useState(null);// { trackId, clickOffset, buffer, name, volume, color } +const[hoveredTrackId,setHoveredTrackId]=useState(null);const openPanel=id=>{if(id==='export')setShowExportPanel(true);else if(id==='ai')setShowAIPanel(true);else if(id==='python_tools')setShowPythonToolsPanel(true);else if(id==='selection')setShowSelectionPanel(true);};const[activeTool,setActiveTool]=useState('select');// 'select' | 'grab' | 'razor' +const[snapValue,setSnapValue]=useState('free');// 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' +const snapTime=(time,snapVal,bpmVal)=>{if(snapVal==='free')return time;const beatDuration=60/parseFloat(bpmVal||120);let divisor=1;if(snapVal==='1')divisor=1;else if(snapVal==='1/2')divisor=0.5;else if(snapVal==='1/4')divisor=0.25;else if(snapVal==='1/8')divisor=0.125;else if(snapVal==='1/16')divisor=0.0625;else if(snapVal==='1/32')divisor=0.03125;const gridSpacing=beatDuration*divisor;return Math.round(time/gridSpacing)*gridSpacing;};const snapValueRef=useRef(snapValue);snapValueRef.current=snapValue;const bpmRef=useRef(bpm);bpmRef.current=bpm;// BMP for Tempo Track - LOOP_EDITOR_2.md §6 +const[selectedTrackId,setSelectedTrackId]=useState('1');const[currentTime,setCurrentTime]=useState(0);const[isPlaying,setIsPlaying]=useState(false);const[selectionStart,setSelectionStart]=useState(null);const[selectionEnd,setSelectionEnd]=useState(null);const selectionRef=useRef({start:null,end:null});selectionRef.current={start:selectionStart,end:selectionEnd};const[selectionMode,setSelectionMode]=useState(null);// 'global' (from ruler) | 'local' (from track) +const[localSelectionTrackId,setLocalSelectionTrackId]=useState(null);const[localSelectionStart,setLocalSelectionStart]=useState(null);const[localSelectionEnd,setLocalSelectionEnd]=useState(null);const[zoom,setZoom]=useState(100);const[isLoopingSelection,setIsLoopingSelection]=useState(false);const[beginBar,setBeginBar]=useState(1);const[endBar,setEndBar]=useState(1);const[numberBar,setNumberBar]=useState(1);const[subTabHeight,setSubTabHeight]=useState(96);const[isExporting,setIsExporting]=useState(false);const[projectName,setProjectName]=useState(()=>localStorage.getItem('sonic_project_name')||'');const[currentProjectId,setCurrentProjectId]=useState(()=>localStorage.getItem('sonic_project_id')||null);const[saveProjectModalOpen,setSaveProjectModalOpen]=useState(false);const[saveAsModalOpen,setSaveAsModalOpen]=useState(false);const[soloedTrackId,setSoloedTrackId]=useState(null);const[toastMessage,setToastMessage]=useState(null);const[showAIConfig,setShowAIConfig]=useState(false);const[showExportPanel,setShowExportPanel]=useState(true);const[showAIPanel,setShowAIPanel]=useState(true);const[showSelectionPanel,setShowSelectionPanel]=useState(true);const[showPythonToolsPanel,setShowPythonToolsPanel]=useState(true);const[showMediaExplorer,setShowMediaExplorer]=useState(true);const[showFxRack,setShowFxRack]=useState(false);const[showMidiEvents,setShowMidiEvents]=useState(false);const[rightSidebarWidth,setRightSidebarWidth]=useState(320);const[mediaExplorerHeight,setMediaExplorerHeight]=useState(50);const[panelPositions,setPanelPositions]=useState({export:'bottom',ai:'right',python_tools:'bottom',selection:'bottom',media_explorer:'bottom',fx_rack:'bottom',midi_events:'bottom'});const[panelDropZone,setPanelDropZone]=useState(null);const[dragGhostPos,setDragGhostPos]=useState(null);const[dragGhostPanel,setDragGhostPanel]=useState(null);const panelDragRef=useRef(null);const workspaceRef=useRef(null);const colResizerRef=useRef(null);const rowResizerRef=useRef(null);const[aiConfig,setAiConfig]=useState({baseUrl:localStorage.getItem('ai_base_url')||`${API_BASE_URL}`,apiKey:localStorage.getItem('ai_api_key')||'',model:localStorage.getItem('ai_model')||'deepseek-chat'});const[aiProviders,setAiProviders]=useState([]);useEffect(()=>{(async()=>{try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){/* server may not have config endpoint */}})();},[]);const[analysisState,setAnalysisState]=useState({status:'Sẵn sàng. Chạy AI để phân tích nhịp.',data:null,isRunning:false});const[aiPrompt,setAiPrompt]=useState('');const[promptHistory,setPromptHistory]=useState([]);const[promptHistIdx,setPromptHistIdx]=useState(-1);const promptHistRef=useRef([]);const[aiProvider,setAiProvider]=useState('OpenAI');const[aiModel,setAiModel]=useState('GPT-4o');const[aiActionLog,setAiActionLog]=useState([]);const actionLogContainerRef=useRef(null);useEffect(()=>{if(actionLogContainerRef.current){actionLogContainerRef.current.scrollTop=actionLogContainerRef.current.scrollHeight;}},[aiActionLog]);const[aiProcessing,setAiProcessing]=useState(false);const[selectedProviderId,setSelectedProviderId]=useState('');const[exportSettings,setExportSettings]=useState({sampleRate:'44100',bitDepth:'16',format:'wav',source:'project',quality:'44khz',channels:'stereo'});const[serverStatus,setServerStatus]=useState('checking...');const[menuOpen,setMenuOpen]=useState(null);const[selectedClipId,setSelectedClipId]=useState(null);// { trackId, clipId } +const[stretchedClip,setStretchedClip]=useState(null);// { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } +const[draggedSectionItem,setDraggedSectionItem]=useState(null);const[resizedSectionItem,setResizedSectionItem]=useState(null);const[editingTrackName,setEditingTrackName]=useState(null);// trackId being edited +const[editingClipName,setEditingClipName]=useState(null);// { trackId, clipId } +const[editNameInput,setEditNameInput]=useState('');// ── Context Menu & Clipboard ── +const[contextMenu,setContextMenu]=useState(null);// { x, y, trackId } +const clipboardRef=useRef(null);// { buffer, name, volume, color } for copy/paste +// ── Undo/Redo Engine (LOOP_EDITOR.md §4) ── +const[undoStack,setUndoStack]=useState([]);const[redoStack,setRedoStack]=useState([]);const MAX_UNDO=30;const pushAction=(actionType,trackId,beforeState,afterState)=>{const node={action_type:actionType,track_id:trackId,timestamp:Date.now(),before_state:beforeState,after_state:afterState};setUndoStack(prev=>{const next=[...prev,node];if(next.length>MAX_UNDO)next.shift();return next;});setRedoStack([]);};const handleUndo=()=>{if(undoStack.length===0)return;const last=undoStack[undoStack.length-1];setUndoStack(prev=>prev.slice(0,-1));setRedoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.before_state);showToast(`Undo: ${last.action_type}`,'info');};const handleRedo=()=>{if(redoStack.length===0)return;const last=redoStack[redoStack.length-1];setRedoStack(prev=>prev.slice(0,-1));setUndoStack(prev=>[...prev,last]);applyTrackState(last.track_id,last.after_state);showToast(`Redo: ${last.action_type}`,'info');};const applyTrackState=(trackId,state)=>{setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,...state};}));};const captureTrackSnapshot=trackId=>{const track=tracks.find(t=>t.id===trackId);if(!track)return null;return{volumeDb:track.volumeDb,pan:track.pan,muted:track.muted,name:track.name,markers:JSON.parse(JSON.stringify(track.markers||[])),// buffer is captured via reference copy for undo; we store a clone for redo +buffer:track.buffer,startTime:track.startTime||0,clips:track.clips?track.clips.map(c=>({id:c.id,buffer:c.buffer,startTime:c.startTime,name:c.name})):null};};// ── Tab System (LOOP_EDITOR_2.md §1) ── +const[activeTab,setActiveTab]=useState('main');const[subTabSelectedNodeTime,setSubTabSelectedNodeTime]=useState(null);const[subTabNormVal,setSubTabNormVal]=useState(0);const[subTabGainVal,setSubTabGainVal]=useState(100);const[subTabPitchVal,setSubTabPitchVal]=useState(0);const[subTabs,setSubTabs]=useState([]);// [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] +const[sessionTabs,setSessionTabs]=useState([]);// [{id, name, tracks}, ...] +const activeTracks=useMemo(()=>{const st=sessionTabs.find(s=>s.id===activeTab);return st?st.tracks:tracks;},[activeTab,sessionTabs,tracks]);const[selectionCleared,setSelectionCleared]=useState(false);// LOOP_EDITOR_2.md §4.2 +// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── +const[tempTabActive,setTempTabActive]=useState(false);const[tempTabBuffer,setTempTabBuffer]=useState(null);const[tempTabTrackId,setTempTabTrackId]=useState(null);const[tempTabOrigStart,setTempTabOrigStart]=useState(0);const[tempTabOrigEnd,setTempTabOrigEnd]=useState(0);const tempTabCanvasRef=useRef(null);// Effect parameters for temp tab +const[tempTabEffects,setTempTabEffects]=useState({reverse:false,gainDb:0,fadeInMs:0,fadeOutMs:0});// ── Auth / User State ── +const[currentUser,setCurrentUser]=useState(null);const[authModalOpen,setAuthModalOpen]=useState(false);const[authMode,setAuthMode]=useState('login');// 'login' | 'register' | 'force_change' +const[isMandatoryLogin,setIsMandatoryLogin]=useState(false);const[profileModalOpen,setProfileModalOpen]=useState(false);const[systemManagerModalOpen,setSystemManagerModalOpen]=useState(false);const[aiConfigModalOpen,setAiConfigModalOpen]=useState(false);useEffect(()=>{const checkAuthStatus=async()=>{const savedToken=localStorage.getItem('sonic_token');if(!savedToken){setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);return;}try{const profile=await window.SonicAPI.getProfile();setCurrentUser(profile);localStorage.setItem('sonic_user',JSON.stringify(profile));if(profile.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);}}catch(err){const cached=localStorage.getItem('sonic_user');if(cached){try{setCurrentUser(JSON.parse(cached));}catch(_){}setIsMandatoryLogin(false);setAuthModalOpen(false);}else{localStorage.removeItem('sonic_token');localStorage.removeItem('sonic_user');setCurrentUser(null);setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);}}};checkAuthStatus();},[]);const loadPendingSfsProject=()=>{const proj=window.__pendingSfsProject;if(!proj)return;try{let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];if(proj.main_session){const result=deserializeProjectFromSchema(proj);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;}else{restoredTracks=(proj.tracks||[]).map(t=>({...t,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null}));}if(restoredTracks.length>0){setTracks(restoredTracks);setBpm(restoredBpm.toString());if(restoredSessionTabs.length>0){setSessionTabs(restoredSessionTabs);}showToast(`Đã tải dự án "${proj.metadata?.title||proj.name||'Dự án mới'}" từ liên kết .sfs thành công!`,"success");}}catch(e){showToast("Lỗi tải dự án từ .sfs","error");}finally{window.__pendingSfsProject=null;}};const handleAuthSuccess=user=>{setCurrentUser(user);if(user.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);loadPendingSfsProject();const loadPrefs=p=>{if(!p)return;if(p.showAIPanel!==undefined)setShowAIPanel(p.showAIPanel);if(p.showExportPanel!==undefined)setShowExportPanel(p.showExportPanel);if(p.showSelectionPanel!==undefined)setShowSelectionPanel(p.showSelectionPanel);if(p.showPythonToolsPanel!==undefined)setShowPythonToolsPanel(p.showPythonToolsPanel);if(p.showMediaExplorer!==undefined)setShowMediaExplorer(p.showMediaExplorer);if(p.showFxRack!==undefined)setShowFxRack(p.showFxRack);if(p.showMidiEvents!==undefined)setShowMidiEvents(p.showMidiEvents);if(p.panelPositions)setPanelPositions(p.panelPositions);if(p.rightSidebarWidth)setRightSidebarWidth(p.rightSidebarWidth);if(p.mediaExplorerHeight)setMediaExplorerHeight(p.mediaExplorerHeight);if(p.selectedProviderId)setSelectedProviderId(p.selectedProviderId);};(async()=>{try{const data=await window.SonicAPI.getPreferences();if(data&&data.preferences)loadPrefs(data.preferences);else{const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}}catch(e){const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){}})();}};const handleLogout=()=>{localStorage.removeItem('sonic_token');localStorage.removeItem('sonic_user');setCurrentUser(null);setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);};// ── Temp project auto-save (local + server) ── +useEffect(()=>{const serializeSafe=arr=>(arr||[]).map(t=>({id:t.id,name:t.name,startTime:t.startTime,height:t.height,volumeDb:t.volumeDb,pan:t.pan,muted:t.muted,solo:t.solo,color:t.color,markers:t.markers||[],serverFileId:t.serverFileId||null,channelInfo:t.channelInfo?{channels:t.channelInfo.channels,isStereo:t.channelInfo.isStereo,label:t.channelInfo.label}:null}));window.SonicStorage.scheduleTempAutoSave(()=>{return serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs);});},[tracks,subTabs,sessionTabs]);// Lucide icons initialization +useEffect(()=>{setTimeout(()=>{if(window.lucide){window.lucide.createIcons();}},50);},[activeTool,activeTab]);const timelineWrapperRef=useRef(null);const[timelineWrapperNode,setTimelineWrapperNode]=useState(null);const handleTimelineWrapperRef=useCallback(node=>{timelineWrapperRef.current=node;setTimelineWrapperNode(node);},[]);const tcpContainerRef=useRef(null);const[scrollLeft,setScrollLeft]=useState(0);const handleTimelineScroll=e=>{if(tcpContainerRef.current){tcpContainerRef.current.scrollTop=e.currentTarget.scrollTop;}setScrollLeft(e.currentTarget.scrollLeft);};const handleTCPScroll=e=>{if(timelineWrapperRef.current){timelineWrapperRef.current.scrollTop=e.currentTarget.scrollTop;}};const panelDropZoneRef=useRef(null);const startPanelDrag=(panelId,e)=>{panelDragRef.current={panelId,startX:e.clientX,startY:e.clientY};setPanelDropZone(null);setDragGhostPanel(panelId);setDragGhostPos({x:e.clientX-120,y:e.clientY-20});const onMove=ev=>{if(!panelDragRef.current||!workspaceRef.current)return;const rect=workspaceRef.current.getBoundingClientRect();const x=ev.clientX-rect.left;const y=ev.clientY-rect.top;const w=rect.width;const h=rect.height;const margin=60;let zone=null;if(x10)zone='left';else if(x>w-margin&&x10)zone='top';else if(y>h-margin&&y{if(panelDragRef.current){const pid=panelDragRef.current.panelId;const targetZone=panelDropZoneRef.current;if(targetZone&&targetZone!==panelPositions[pid]){setPanelPositions(prev=>({...prev,[pid]:targetZone}));}panelDragRef.current=null;panelDropZoneRef.current=null;setPanelDropZone(null);setDragGhostPos(null);setDragGhostPanel(null);}document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};// ── Right Sidebar Column Resizer ── +const startColResize=e=>{e.preventDefault();const startX=e.clientX;const startWidth=rightSidebarWidth;const onMove=ev=>{const deltaX=startX-ev.clientX;const newWidth=Math.max(200,Math.min(600,startWidth+deltaX));setRightSidebarWidth(newWidth);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};// ── Right Sidebar Row Resizer (Media Explorer / AI Panel) ── +const startRowResize=e=>{e.preventDefault();const startY=e.clientY;const startHeight=mediaExplorerHeight;const sidebarEl=document.getElementById('right-sidebar');const sidebarHeight=sidebarEl?sidebarEl.getBoundingClientRect().height:400;const onMove=ev=>{const deltaY=startY-ev.clientY;const pct=(startHeight/100*sidebarHeight+deltaY)/sidebarHeight*100;const newPct=Math.max(20,Math.min(80,pct));setMediaExplorerHeight(newPct);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};const rulerRef=useRef(null);const activeSourcesRef=useRef([]);const activeTrackNodesRef=useRef({});// { [trackId]: { gainNode, pannerNode } } +const startOffsetTimeRef=useRef(0);const startBufferOffsetRef=useRef(0);const startAudioTimeRef=useRef(0);const animationFrameIdRef=useRef(null);const toastTimeoutRef=useRef(null);const rulerDragStartRef=useRef(null);const rulerAnchorRef=useRef(null);const isDraggingRulerRef=useRef(false);const handlePlayPauseRef=useRef(null);const currentTimeRef=useRef(currentTime);currentTimeRef.current=currentTime;// ── Keyboard Shortcuts ── +const handleUndoRef=useRef(handleUndo);const handleRedoRef=useRef(handleRedo);handleUndoRef.current=handleUndo;handleRedoRef.current=handleRedo;const selectedClipIdRef=useRef(null);selectedClipIdRef.current=selectedClipId;const activeTabRef=useRef(activeTab);activeTabRef.current=activeTab;const activePlaybackSpeedRef=useRef(1.0);const subTabsRef=useRef(subTabs);subTabsRef.current=subTabs;const subTabSelectedNodeTimeRef=useRef(null);subTabSelectedNodeTimeRef.current=subTabSelectedNodeTime;const handleSubTabNormalize=tabId=>{setSubTabs(prev=>prev.map(st=>{if(st.id!==tabId||!st.buffer)return st;const ctx=getAudioContext();const data=st.buffer.getChannelData(0);const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):0;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):st.buffer.duration;const startSample=Math.floor(left*st.buffer.sampleRate);const endSample=Math.floor(right*st.buffer.sampleRate);let maxVal=0;for(let i=startSample;imaxVal)maxVal=abs;}if(maxVal===0)return st;const scale=1.0/maxVal;const clonedBuffer=ctx.createBuffer(1,data.length,st.buffer.sampleRate);const clonedData=clonedBuffer.getChannelData(0);clonedData.set(data);for(let i=startSample;i{setSubTabs(prev=>prev.map(st=>{if(st.id!==tabId||!st.buffer)return st;const ctx=getAudioContext();const data=st.buffer.getChannelData(0);const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):0;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):st.buffer.duration;const startSample=Math.floor(left*st.buffer.sampleRate);const endSample=Math.floor(right*st.buffer.sampleRate);const scale=Math.pow(10,gainDb/20);const clonedBuffer=ctx.createBuffer(1,data.length,st.buffer.sampleRate);const clonedData=clonedBuffer.getChannelData(0);clonedData.set(data);for(let i=startSample;i{setSubTabs(prev=>prev.map(st=>{if(st.id!==tabId||!st.buffer)return st;const ctx=getAudioContext();const data=st.buffer.getChannelData(0);const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):0;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):st.buffer.duration;const startSample=Math.floor(left*st.buffer.sampleRate);const endSample=Math.floor(right*st.buffer.sampleRate);const durationSamples=endSample-startSample;if(durationSamples<=0)return st;const clonedBuffer=ctx.createBuffer(1,data.length,st.buffer.sampleRate);const clonedData=clonedBuffer.getChannelData(0);clonedData.set(data);if(type==='in'){for(let i=0;i{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Cut.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=endSample-startSample;const cutBuffer=ctx.createBuffer(1,len,sr);cutBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:cutBuffer,name:'Subtab Clip'};const newBuffer=ctx.createBuffer(1,data.length-len,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;iprev.map(s=>s.id===tabId?{...s,buffer:newBuffer,currentTime:left,selectionStart:null,selectionEnd:null}:s));showToast('Đã Cut vùng chọn.','success');};const handleSubTabCopy=tabId=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Copy.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=endSample-startSample;const copyBuffer=ctx.createBuffer(1,len,sr);copyBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:copyBuffer,name:'Subtab Clip',sampleRate:sr,channels:1,speed:1.0};showToast('Đã Copy vùng chọn.','success');};const handleSubTabPaste=tabId=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;if(!clipboardRef.current||!clipboardRef.current.buffer){showToast('Clipboard trống.','warning');return;}const ctx=getAudioContext();const clipBuf=clipboardRef.current.buffer;const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const insertTime=st.currentTime||0;const insertSample=Math.floor(insertTime*sr);const newBuffer=ctx.createBuffer(1,data.length+clipBuf.length,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;iprev.map(s=>s.id===tabId?{...s,buffer:newBuffer,currentTime:insertTime+clipBuf.duration,selectionStart:null,selectionEnd:null}:s));showToast('Đã dán dữ liệu âm thanh.','success');};const handleSubTabDelete=tabId=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Delete.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=endSample-startSample;const newBuffer=ctx.createBuffer(1,data.length-len,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;iprev.map(s=>s.id===tabId?{...s,buffer:newBuffer,currentTime:left,selectionStart:null,selectionEnd:null}:s));showToast('Đã xóa vùng chọn.','success');};const handleSubTabLoop=(tabId,loopCount)=>{const st=subTabsRef.current.find(s=>s.id===tabId);if(!st||!st.buffer)return;const left=st.selectionStart!==null&&st.selectionEnd!==null?Math.min(st.selectionStart,st.selectionEnd):null;const right=st.selectionStart!==null&&st.selectionEnd!==null?Math.max(st.selectionStart,st.selectionEnd):null;if(left===null||right===null||left===right){showToast('Vui lòng chọn vùng để Loop.','warning');return;}const ctx=getAudioContext();const sr=st.buffer.sampleRate;const data=st.buffer.getChannelData(0);const startSample=Math.floor(left*sr);const endSample=Math.floor(right*sr);const len=endSample-startSample;// Loop payload N times +const segmentData=data.subarray(startSample,endSample);const addedSamples=len*(loopCount-1);const newBuffer=ctx.createBuffer(1,data.length+addedSamples,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;iprev.map(s=>s.id===tabId?{...s,buffer:newBuffer,selectionStart:null,selectionEnd:null}:s));showToast(`Đã lặp vùng chọn ${loopCount} lần.`,'success');};useEffect(()=>{const handler=e=>{// Bypass global hotkeys when typing inside input/textarea/contentEditable elements +if(e.target&&(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.isContentEditable)){return;}const ctrl=e.ctrlKey||e.metaKey;const alt=e.altKey;// Global space play/pause shortcut for transport +if(e.key===' '||e.code==='Space'){e.preventDefault();if(handlePlayPauseRef.current)handlePlayPauseRef.current();return;}if(activeTabRef.current!=='main'){// Sub-Tab keyboard shortcuts mapping +const curTabId=activeTabRef.current;if(ctrl&&alt&&e.key==='n'){e.preventDefault();handleSubTabNormalize(curTabId);return;}if(e.key==='f'||e.key==='F'){e.preventDefault();handleSubTabFade(curTabId,'in');return;}if(e.key==='g'||e.key==='G'){e.preventDefault();handleSubTabFade(curTabId,'out');return;}if(ctrl&&e.key==='l'){e.preventDefault();handleSubTabLoop(curTabId,4);return;}if(e.key==='v'||e.key==='V'){e.preventDefault();const val=prompt("Nhập Gain điều chỉnh (dB):","0");if(val)handleSubTabGain(curTabId,parseFloat(val)||0);return;}if(ctrl&&e.key==='x'){e.preventDefault();handleSubTabCut(curTabId);return;}if(ctrl&&e.key==='c'){e.preventDefault();handleSubTabCopy(curTabId);return;}if(ctrl&&e.key==='v'){e.preventDefault();handleSubTabPaste(curTabId);return;}if(e.key==='Delete'||e.key==='Backspace'||e.key==='Del'){e.preventDefault();if(subTabSelectedNodeTimeRef.current!==null){const selTime=subTabSelectedNodeTimeRef.current;setSubTabs(prev=>prev.map(s=>{if(s.id!==curTabId)return s;const curNodes=s.graphMode==='pan'?s.panningNodes||[]:s.volumeNodes||[];const updated=curNodes.filter(n=>n.time!==selTime);return{...s,[s.graphMode==='pan'?'panningNodes':'volumeNodes']:updated};}));setSubTabSelectedNodeTime(null);}else{handleSubTabDelete(curTabId);}return;}return;}if(ctrl&&e.key==='z'&&!e.shiftKey){e.preventDefault();handleUndoRef.current();return;}if(ctrl&&(e.key==='y'||e.key==='z'&&e.shiftKey)){e.preventDefault();handleRedoRef.current();return;}if(ctrl&&!alt&&e.key==='o'){e.preventDefault();handleImportSFS();return;}if(ctrl&&!alt&&e.key==='n'){e.preventDefault();setTracks([{id:'1',name:'Track 01',buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:'#0f766e',markers:[],serverFileId:null},{id:'2',name:'Track 02',buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:'#1d4ed8',markers:[],serverFileId:null}]);setSelectedTrackId('1');showToast('New project created','info');return;}if(ctrl&&!alt&&e.key==='s'){e.preventDefault();handleExportSFS();return;}if(ctrl&&alt&&e.key==='s'||ctrl&&e.shiftKey&&e.key==='s'){e.preventDefault();handleExportSFS();return;}if(ctrl&&!alt&&e.key==='i'){e.preventDefault();addNewTrack();return;}if(ctrl&&alt&&e.key==='i'){e.preventDefault();showToast('Import audio','info');return;}if(ctrl&&!alt&&e.key==='e'){e.preventDefault();openTempTab();return;}if(ctrl&&!alt&&e.key==='m'){e.preventDefault();handleMergeTracks();return;}if(ctrl&&!alt&&e.key==='c'){e.preventDefault();handleCopyTrack();return;}if(ctrl&&!alt&&e.key==='x'){e.preventDefault();handleCutTrack();return;}if(ctrl&&!alt&&e.key==='v'){e.preventDefault();handlePasteTrack();return;}if(e.key==='Delete'||e.key==='Backspace'||e.key==='Del'){const selClip=selectedClipIdRef.current;if(selClip){e.preventDefault();const{trackId,clipId}=selClip;setTracks(prev=>{const track=prev.find(t=>t.id===trackId);if(!track)return prev;const beforeSnap=captureTrackSnapshotRef.current?captureTrackSnapshotRef.current(trackId):null;const updatedClips=(track.clips||[]).filter(c=>c.id!==clipId);const updatedTracks=prev.map(t=>{if(t.id===trackId){return{...t,clips:updatedClips,buffer:updatedClips.length>0?updatedClips[0].buffer:null,startTime:updatedClips.length>0?updatedClips[0].startTime:0,name:updatedClips.length>0?updatedClips[0].name:`Track ${t.id}`};}return t;});setTimeout(()=>{const afterSnap=captureTrackSnapshotRef.current?captureTrackSnapshotRef.current(trackId):null;pushAction('DELETE_CLIP',trackId,beforeSnap,afterSnap);},50);return updatedTracks;});setSelectedClipId(null);showToast('Đã xóa clip.','info');return;}else{e.preventDefault();handleDeleteTrack();return;}}if(ctrl&&!alt&&e.key==='s'){e.preventDefault();handleSaveProject();return;}if(!ctrl&&!alt&&e.key==='s'){e.preventDefault();handleSplitTrack(selectedTrackId);return;}};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler);},[]);// ── Temp Tab: draw isolated waveform ── +useEffect(()=>{if(!tempTabActive||!tempTabBuffer||!tempTabCanvasRef.current)return;const canvas=tempTabCanvasRef.current;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const rect=canvas.getBoundingClientRect();canvas.width=rect.width*dpr;canvas.height=rect.height*dpr;ctx.scale(dpr,dpr);const w=rect.width;const h=rect.height;ctx.fillStyle='#181818';ctx.fillRect(0,0,w,h);const data=tempTabBuffer.getChannelData(0);const sr=tempTabBuffer.sampleRate;const totalSamples=data.length;if(totalSamples===0)return;ctx.strokeStyle='#6ee7b7';ctx.lineWidth=1;for(let px=0;pxmaxVal)maxVal=abs;}const mid=h/2;const peakHeight=maxVal*(h*0.4);ctx.beginPath();ctx.moveTo(px,mid-peakHeight);ctx.lineTo(px,mid+peakHeight);ctx.stroke();}},[tempTabActive,tempTabBuffer]);// ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ── +const openTempTab=()=>{const useLocal=selectionMode==='local'&&localSelectionTrackId;const trackId=useLocal?localSelectionTrackId:selectedTrackId;const t=tracks.find(x=>x.id===trackId);if(!t||!t.buffer){showToast('Vui lòng chọn track có dữ liệu âm thanh.','warning');return;}if(selLeft===null||selRight===null||selRight<=selLeft){showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.','warning');return;}// Check if a subtab for this track+range already exists +const existing=subTabs.find(s=>s.trackId===trackId&&s.startTime===selLeft&&s.endTime===selRight);if(existing){setActiveTab(existing.id);showToast(`Sub Tab already open.`,'info');return;}const sr=t.buffer.sampleRate;const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.max(0,Math.floor(relSelLeft*sr));const endSample=Math.min(t.buffer.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len<100){showToast('Khoảng chọn quá ngắn.','warning');return;}const ctx=getAudioContext();const numChannels=t.buffer.numberOfChannels||1;const subBuffer=ctx.createBuffer(numChannels,len,sr);for(let c=0;c[...prev,{id:tabId,label:tabLabel,trackId:trackId,startTime:selLeft,endTime:selRight,buffer:subBuffer,channelInfo:subChannelInfo,effects:{reverse:false,gainDb:0,fadeInMs:0,fadeOutMs:0,normalizeDb:0,pitch:0,speedStretch:100},currentTime:0,selectionStart:null,selectionEnd:null,isPlaying:false,speed:1.0,volumeNodes:[],panningNodes:[],fadeInLen:0,fadeOutLen:0,graphMode:null,isLooping:false,loopCount:0}]);setActiveTab(tabId);};const handleEditClipInSubTab=(trackId,clipId)=>{const track=activeTracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];const clip=clips.find(c=>c.id===clipId||clipId==='default'&&c.id==='default_'+trackId);if(!clip||!clip.buffer)return;const resolvedClipId=clip.id==='default'?'default_'+trackId:clip.id;const existing=subTabs.find(s=>s.clipId===resolvedClipId&&s.trackId===trackId);if(existing){setActiveTab(existing.id);showToast(`Sub Tab for "${clip.name}" already open.`,'info');return;}const sr=clip.buffer.sampleRate;const len=clip.buffer.length;const numChannels=clip.buffer.numberOfChannels||1;const ctx=getAudioContext();const subBuffer=ctx.createBuffer(numChannels,len,sr);for(let c=0;c[...prev,{id:tabId,label:tabLabel,trackId:trackId,clipId:resolvedClipId,startTime:clip.startTime,endTime:clip.startTime+clip.buffer.duration,buffer:subBuffer,channelInfo:subChannelInfo,effects:{reverse:false,gainDb:0,fadeInMs:0,fadeOutMs:0,normalizeDb:0,pitch:0,speedStretch:100},currentTime:0,selectionStart:null,selectionEnd:null,isPlaying:false,speed:1.0,volumeNodes:[],panningNodes:[],fadeInLen:0,fadeOutLen:0,graphMode:null,isLooping:false,loopCount:0}]);setActiveTab(tabId);};const handleEditMidiInTab=(trackId,midiItemId)=>{const track=activeTracks.find(t=>t.id===trackId);if(!track)return;const midiItem=(track.midiItems||[]).find(m=>m.id===midiItemId);if(!midiItem)return;const existing=subTabs.find(s=>s.type==='PIANO_ROLL'&&s.target_id===midiItemId);if(existing){setActiveTab(existing.id);showToast('Piano Roll cho nốt MIDI đã được mở.','info');return;}const tabId='midi_'+Date.now();const tabLabel=`Piano Roll: ${midiItem.name||'MIDI'}`;const newTab={id:tabId,label:tabLabel,type:'PIANO_ROLL',trackId:trackId,target_id:midiItemId,parent_tab_id:activeTab==='main'?null:activeTab,notes:midiItem.notes||[],duration:midiItem.duration||4,viewport_start_bar:0.0,viewport_bar_width:8.0,scroll_y_pitch:60,snap_resolution:snapValue||"1/16",note_selection:[]};setSubTabs(prev=>[...prev,newTab]);setActiveTab(tabId);};const handleUpdateMidiNotes=(tabId,trackId,midiItemId,updatedNotes)=>{setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,notes:updatedNotes}:s));const isSectionTrack=sessionTabs.some(s=>s.tracks.some(t=>t.id===trackId));if(isSectionTrack){setSessionTabs(prev=>prev.map(s=>{if(!s.tracks.some(t=>t.id===trackId))return s;return{...s,tracks:s.tracks.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:(t.midiItems||[]).map(m=>{if(m.id!==midiItemId)return m;return{...m,notes:updatedNotes};})};})};}));}else{setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:(t.midiItems||[]).map(m=>{if(m.id!==midiItemId)return m;return{...m,notes:updatedNotes};})};}));}};// ── Double-click/Edit Section: open Main Session in new tab ── +const handleEditSectionInTab=(trackId,sectionId)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const section=(track.sections||[]).find(s=>s.id===sectionId);if(!section)return;const existing=sessionTabs.find(s=>s.sectionId===sectionId);if(existing){setActiveTab(existing.id);showToast(`Tab "${section.name}" already open.`,'info');return;}const tabId='session_'+Date.now();const tabName=section.name||'Section';const clonedTracks=tracks.map(t=>({...t,clips:[],sections:[],midiItems:[],markers:[]}));setSessionTabs(prev=>[...prev,{id:tabId,name:tabName,sectionId:sectionId,tracks:clonedTracks}]);setActiveTab(tabId);};// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── +const applySubTab=tabId=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;const track=activeTracks.find(t=>t.id===subTab.trackId);if(!track||!track.buffer)return;const beforeSnap=captureTrackSnapshot(subTab.trackId);// Clone buffer and apply effects +const ctx=getAudioContext();const sr=subTab.buffer.sampleRate;const eff=subTab.buffer.getChannelData(0);let resultBuffer=ctx.createBuffer(1,eff.length,sr);let resultData=resultBuffer.getChannelData(0);resultData.set(eff);// Apply effects inline +const fx=subTab.effects||{};const applyResample=(data,ratio)=>{const newLen=Math.round(data.length*ratio);const out=new Float32Array(newLen);for(let i=0;i0){const fadeSamples=Math.min(resultData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;i0){const fadeSamples=Math.min(resultData.length,Math.floor(gFadeIn*sr));for(let i=0;i0){const fadeSamples=Math.min(resultData.length,Math.floor(gFadeOut*sr));for(let i=resultData.length-fadeSamples;i0){const sortedNodes=[...volNodes].sort((a,b)=>a.time-b.time);const computeHermiteTangents=pts=>{const n=pts.length;if(n<2)return[];const m=new Array(n);for(let i=1;i1?(pts[1].db-pts[0].db)/(pts[1].time-pts[0].time):0;m[n-1]=n>1?(pts[n-1].db-pts[n-2].db)/(pts[n-1].time-pts[n-2].time):0;return m;};const tangents=computeHermiteTangents(sortedNodes);const getVolumeGainAtTime=t=>{if(sortedNodes.length===1){return Math.pow(10,sortedNodes[0].db/20);}if(t<=sortedNodes[0].time){return Math.pow(10,sortedNodes[0].db/20);}if(t>=sortedNodes[sortedNodes.length-1].time){return Math.pow(10,sortedNodes[sortedNodes.length-1].db/20);}for(let i=0;i=n1.time&&t<=n2.time){const h=n2.time-n1.time;if(h<=0)return Math.pow(10,n1.db/20);const frac=(t-n1.time)/h;const frac2=frac*frac,frac3=frac2*frac;const db=(2*frac3-3*frac2+1)*n1.db+(frac3-2*frac2+frac)*h*tangents[i]+(-2*frac3+3*frac2)*n2.db+(frac3-frac2)*h*tangents[i+1];return Math.pow(10,db/20);}}return 1.0;};for(let i=0;imaxVal)maxVal=abs;}if(maxVal>0){const targetAmp=Math.pow(10,fx.normalizeDb/20);const scale=targetAmp/maxVal;for(let i=0;is.tracks.some(t=>t.id===subTab.trackId));if(isSectionTrack){if(subTab.clipId){setSessionTabs(prev=>prev.map(s=>{if(!s.tracks.some(t=>t.id===subTab.trackId))return s;return{...s,tracks:s.tracks.map(t=>{if(t.id!==subTab.trackId)return t;const updatedClips=(t.clips||[]).map(c=>{if(c.id===subTab.clipId){return{...c,buffer:resultBuffer,name:c.name.endsWith('(edited)')?c.name:c.name+' (edited)'};}return c;});return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};})};}));}else{// Crossfade merge into original track (§2.2) +const sr=track.buffer.sampleRate;const origData=track.buffer.getChannelData(0);const trackStart=track.startTime||0;const startSample=Math.floor((subTab.startTime-trackStart)*sr);const endSample=Math.floor((subTab.endTime-trackStart)*sr);const crossfadeLen=Math.min(100,Math.floor(0.01*sr));// 10ms +const mergedBuffer=ctx.createBuffer(1,track.buffer.length,sr);const mergedData=mergedBuffer.getChannelData(0);for(let i=0;iresultData.length-crossfadeLen){const distFromEnd=resultData.length-1-i;const alpha=distFromEnd/crossfadeLen;const origEndIdx=endSample-(resultData.length-i);val=alpha*(origEndIdx>=0?origData[origEndIdx]:0)+(1-alpha)*resultData[i];}mergedData[globalIdx]=val;}setSessionTabs(prev=>prev.map(s=>{if(!s.tracks.some(t=>t.id===subTab.trackId))return s;return{...s,tracks:s.tracks.map(t=>{if(t.id!==subTab.trackId)return t;return{...t,buffer:mergedBuffer,name:t.name+' (edited)'};})};}));}}else{if(subTab.clipId){// Clip-based merge +setTracks(prev=>prev.map(t=>{if(t.id!==subTab.trackId)return t;const updatedClips=(t.clips||[]).map(c=>{if(c.id===subTab.clipId){return{...c,buffer:resultBuffer,name:c.name.endsWith('(edited)')?c.name:c.name+' (edited)'};}return c;});return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));}else{// Crossfade merge into original track (§2.2) +const sr=track.buffer.sampleRate;const origData=track.buffer.getChannelData(0);const trackStart=track.startTime||0;const startSample=Math.floor((subTab.startTime-trackStart)*sr);const endSample=Math.floor((subTab.endTime-trackStart)*sr);const crossfadeLen=Math.min(100,Math.floor(0.01*sr));// 10ms +const mergedBuffer=ctx.createBuffer(1,track.buffer.length,sr);const mergedData=mergedBuffer.getChannelData(0);for(let i=0;iresultData.length-crossfadeLen){const distFromEnd=resultData.length-1-i;const alpha=distFromEnd/crossfadeLen;const origEndIdx=endSample-(resultData.length-i);val=alpha*(origEndIdx>=0?origData[origEndIdx]:0)+(1-alpha)*resultData[i];}mergedData[globalIdx]=val;}setTracks(prev=>prev.map(t=>{if(t.id!==subTab.trackId)return t;return{...t,buffer:mergedBuffer,name:t.name+' (edited)'};}));}}const afterSnap=captureTrackSnapshot(subTab.trackId);pushAction('EDIT_TAB',subTab.trackId,beforeSnap,afterSnap);// Tab Lifetime: Engaging the Apply trigger propagates data back to the primary environment but does not close down the active sub-tab view. +// closeSubTab(tabId); +showToast('Đã áp dụng chỉnh sửa vào track chính.','success');};const applySubTabEffect=(tabId,effectType,value)=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;const ctx=getAudioContext();const sr=subTab.buffer.sampleRate;const eff=subTab.buffer.getChannelData(0);// Clone buffer +let resultBuffer=ctx.createBuffer(1,eff.length,sr);let resultData=resultBuffer.getChannelData(0);resultData.set(eff);// Calculate selection start/end in unstretched sample indices +const speed=subTab.speed||1.0;const hasSelection=subTab.selectionStart!==null&&subTab.selectionEnd!==null&&subTab.selectionStart!==subTab.selectionEnd;const tStart=hasSelection?Math.min(subTab.selectionStart,subTab.selectionEnd)*speed:0;const tEnd=hasSelection?Math.max(subTab.selectionStart,subTab.selectionEnd)*speed:subTab.buffer.duration;const startSample=Math.max(0,Math.min(eff.length,Math.floor(tStart*sr)));const endSample=Math.max(0,Math.min(eff.length,Math.floor(tEnd*sr)));if(effectType==='normalize'){let maxVal=0;for(let i=startSample;imaxVal)maxVal=abs;}if(maxVal>0){const targetAmp=Math.pow(10,value/20);const scale=targetAmp/maxVal;for(let i=startSample;i{const newLen=Math.round(data.length*r);const out=new Float32Array(newLen);for(let i=0;iprev.map(s=>{if(s.id!==tabId)return s;return{...s,buffer:resultBuffer,selectionStart:null,selectionEnd:null};}));showToast(`Đã áp dụng ${effectType==='normalize'?'Normalize':effectType==='gain'?'Gain':'Pitch'} cho ${hasSelection?'vùng chọn':'toàn bộ clip'}.`,'success');};const exportSubTabBuffer=async tabId=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;try{const buffer=subTab.buffer;const sr=buffer.sampleRate;const monoData=buffer.getChannelData(0);const bufferLength=monoData.length;const bitDepth=16;const bytesPerSample=2;const headerSize=44;const fileSizeBytes=headerSize+bufferLength*bytesPerSample;const fileBuffer=new ArrayBuffer(fileSizeBytes);const view=new DataView(fileBuffer);const writeString=(offset,string)=>{for(let i=0;i{setSubTabs(prev=>prev.filter(s=>s.id!==tabId));if(activeTab===tabId)setActiveTab('main');};const closeSessionTab=tabId=>{const targetTab=sessionTabs.find(s=>s.id===tabId);if(!targetTab)return;const activeChildTabs=subTabs.filter(s=>s.parent_tab_id===tabId);if(activeChildTabs.length>0){setAppWarningModal({title:"Không thể đóng Tab Section",message:`Vui lòng đóng các Tab con biên tập trước khi đóng Tab Section này:\n\n`+activeChildTabs.map(t=>`• ${t.label||t.name}`).join("\n"),isAlert:true});return;}setSessionTabs(prev=>prev.filter(s=>s.id!==tabId));if(activeTab===tabId)setActiveTab('main');};const updateSubTabEffects=(tabId,effects)=>{setSubTabs(prev=>prev.map(s=>s.id===tabId?{...s,effects:{...s.effects,...effects}}:s));};const handleSubTabNormalizeWithValue=(tabId,normalizeDb)=>{updateSubTabEffects(tabId,{normalizeDb});};const handleSubTabGainWithValue=(tabId,gainDb)=>{updateSubTabEffects(tabId,{gainDb});};const handleSubTabPitch=(tabId,pitch)=>{updateSubTabEffects(tabId,{pitch});};const handleSubTabStretch=(tabId,speedStretch)=>{updateSubTabEffects(tabId,{speedStretch});};const handleSubTabFadeState=(tabId,type)=>{const st=subTabs.find(s=>s.id===tabId);if(!st)return;const fx=st.effects||{};if(type==='in'){updateSubTabEffects(tabId,{fadeInMs:(fx.fadeInMs||0)+10});}else if(type==='out'){updateSubTabEffects(tabId,{fadeOutMs:(fx.fadeOutMs||0)+10});}};// ── Context Menu Handlers ── +const handleContextMenu=(e,trackId,clickTime,sectionId)=>{e.preventDefault();e.stopPropagation();setContextMenu({x:e.clientX,y:e.clientY,trackId,time:clickTime||currentTime,sectionId:sectionId||null});};const closeContextMenu=()=>setContextMenu(null);const contextMenuEditSection=()=>{if(contextMenu.sectionId){handleEditSectionInTab(contextMenu.trackId,contextMenu.sectionId);}closeContextMenu();};// Close context menu on any click outside +useEffect(()=>{const handler=()=>{if(contextMenu)closeContextMenu();};if(contextMenu){window.addEventListener('click',handler);return()=>window.removeEventListener('click',handler);}},[contextMenu]);const contextMenuEdit=()=>{const track=activeTracks.find(t=>t.id===contextMenu.trackId);if(track)setSelectedTrackId(contextMenu.trackId);closeContextMenu();if(!track)return;const secondsPerBar=60.0/(parseInt(bpm)||120)*4;// Check MIDI items first +const midiItems=track.midiItems||[];const clickedMidi=midiItems.find(m=>contextMenu.time>=m.startTime&&contextMenu.time0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];const clickedClip=clips.find(c=>contextMenu.time>=c.startTime&&contextMenu.time{closeContextMenu();handleSplitTrack(contextMenu.trackId);};const contextMenuDelete=()=>{const tid=contextMenu.trackId;const sessionTab=sessionTabs.find(s=>s.id===activeTab);if(sessionTab){updateActiveTracks(prev=>prev.filter(t=>t.id!==tid));if(selectedTrackId===tid)setSelectedTrackId('1');closeContextMenu();showToast('Đã xoá track.','info');}else{const track=tracks.find(t=>t.id===tid);if(track&&track.sections&&track.sections.length>0){closeContextMenu();showToast('Không thể xoá track chứa Section item.','warning');return;}const beforeSnap=captureTrackSnapshot(tid);setTracks(prev=>prev.filter(t=>t.id!==tid));const afterSnap=captureTrackSnapshot(tid);pushAction('DELETE',tid,beforeSnap,afterSnap);if(selectedTrackId===tid)setSelectedTrackId(tracks.filter(t=>t.id!==tid)[0]?.id||'1');closeContextMenu();showToast('Đã xoá track.','info');}};const contextMenuCopy=()=>{const track=activeTracks.find(t=>t.id===contextMenu.trackId);if(!track||!track.buffer)return;const sr=track.buffer.sampleRate;const data=track.buffer.getChannelData(0);// Copy selected region if selection exists +if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=track.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){const ctx=getAudioContext();const numCh=track.buffer.numberOfChannels||1;const clipBuffer=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch{const t=tracks.find(x=>x.id===contextMenu.trackId);if(!t||!t.buffer){contextMenuDelete();return;}const beforeSnap=captureTrackSnapshot(contextMenu.trackId);const sr=t.buffer.sampleRate;const data=t.buffer.getChannelData(0);if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){const ctx=getAudioContext();const numCh=t.buffer.numberOfChannels||1;const clipBuffer=ctx.createBuffer(numCh,len,sr);for(let ch=0;chp.map(tr=>tr.id===contextMenu.trackId?{...tr,buffer:newBuffer}:tr));const afterSnap=captureTrackSnapshot(contextMenu.trackId);pushAction('CUT',contextMenu.trackId,beforeSnap,afterSnap);closeContextMenu();showToast('Đã cắt vùng chọn vào clipboard.','info');return;}}contextMenuCopy();contextMenuDelete();};const doPaste=(targetTrackId,pasteTime)=>{if(!clipboardRef.current||!clipboardRef.current.buffer){showToast('Clipboard trống.','warning');return null;}const{buffer:clipBuffer,name,volumeDb,pan,color,sampleRate,channels,speed}=clipboardRef.current;const ctx=getAudioContext();const targetTrack=tracks.find(t=>t.id===targetTrackId);const newClip={id:nextClipId(),startTime:pasteTime,buffer:clipBuffer,name:(name||'Pasted Clip').replace(/\.\w+$/,'')+' (Pasted)',...(volumeDb!==undefined?{volumeDb}:{}),...(pan!==undefined?{pan}:{}),...(color?{color}:{}),...(speed!==undefined?{speed}:{})};if(targetTrack){setTracks(p=>p.map(t=>{if(t.id===targetTrackId){const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=[...existingClips,newClip];return{...t,clips:updatedClips,buffer:updatedClips[0].buffer,startTime:updatedClips[0].startTime,name:name||t.name,volumeDb:volumeDb??t.volumeDb,pan:pan??t.pan,color:color||t.color};}return t;}));setCurrentTime(pasteTime);showToast('Đã dán clip vào track.','success');return targetTrackId;}// No matching track — create a new one +const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const newId='track_pasted_'+Date.now();setTracks(prev=>[...prev,{id:newId,name:`Pasted_${name||'track'}`,buffer:clipBuffer,startTime:pasteTime,clips:[newClip],volumeDb:volumeDb??0,pan:pan??0,muted:false,solo:false,color:color||colors[prev.length%colors.length],markers:[],serverFileId:null}]);setSelectedTrackId(newId);setCurrentTime(pasteTime);showToast('Đã dán track mới từ clipboard.','success');return newId;};const handlePasteTrack=()=>doPaste(selectedTrackId,currentTime);const contextMenuPaste=()=>{const result=doPaste(contextMenu.trackId,contextMenu.time||currentTime);closeContextMenu();};const contextMenuMerge=()=>{const activeTracks=tracks.filter(t=>t.buffer&&!t.muted);if(activeTracks.length<2){showToast('Cần ít nhất 2 track có dữ liệu để merge.','warning');closeContextMenu();return;}const ctx=getAudioContext();const maxDur=Math.max(...activeTracks.map(t=>(t.startTime||0)+t.buffer.duration));const sr=activeTracks[0].buffer.sampleRate;const merged=ctx.createBuffer(1,Math.ceil(maxDur*sr),sr);const mergedData=merged.getChannelData(0);activeTracks.forEach(t=>{const data=t.buffer.getChannelData(0);const startSample=Math.floor((t.startTime||0)*sr);const volLinear=(t.volumeDb??0)<=-50?0:Math.pow(10,(t.volumeDb??0)/20);for(let i=0;imaxPeak)maxPeak=abs;}if(maxPeak>1.0){for(let i=0;it.name).join('+').slice(0,30);setTracks(prev=>[...prev,{id:newId,name:`Merged_${names}.wav`,buffer:merged,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,color:colors[prev.length%colors.length],markers:[],serverFileId:null}]);setSelectedTrackId(newId);closeContextMenu();showToast(`Đã merge ${activeTracks.length} tracks.`,'success');};// Menu bar direct handlers (don't rely on contextMenu state) +const handleMergeTracks=()=>{const at=tracks.filter(t=>t.buffer&&!t.muted);if(at.length<2){showToast('Cần 2+ tracks để merge.','warning');return;}const actx=getAudioContext();const maxDur=Math.max(...at.map(t=>(t.startTime||0)+t.buffer.duration));const sr=at[0].buffer.sampleRate;const mb=actx.createBuffer(1,Math.ceil(maxDur*sr),sr);const mdata=mb.getChannelData(0);at.forEach(t=>{const d=t.buffer.getChannelData(0);const startSample=Math.floor((t.startTime||0)*sr);const volLinear=(t.volumeDb??0)<=-50?0:Math.pow(10,(t.volumeDb??0)/20);for(let i=0;imp)mp=a;}if(mp>1.0)for(let i=0;i[...p,{id:'merged_'+Date.now(),name:'Merged_mix.wav',buffer:mb,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:['#0f766e','#1d4ed8'][p.length%2],markers:[],serverFileId:null}]);showToast('Merged all unmuted tracks.','success');};const handleCopyTrack=()=>{const t=tracks.find(x=>x.id===selectedTrackId);if(!t||!t.buffer)return;const sr=t.buffer.sampleRate;const data=t.buffer.getChannelData(0);// If selection exists, copy only the selected region +if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){const ctx=getAudioContext();const clipBuffer=ctx.createBuffer(1,len,sr);clipBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:clipBuffer,name:t.name,volumeDb:t.volumeDb,color:t.color};showToast('Copied selection to clipboard.','info');return;}}// No selection: copy entire track +clipboardRef.current={buffer:clipBuffer,name:t.name,volumeDb:t.volumeDb,pan:t.pan,color:t.color,sampleRate:clipBuffer.sampleRate,channels:clipBuffer.numberOfChannels,speed:t.speed||1.0};showToast('Copied track to clipboard.','info');};const handleCutTrack=()=>{const t=tracks.find(x=>x.id===selectedTrackId);if(!t||!t.buffer)return;const beforeSnap=captureTrackSnapshot(selectedTrackId);const sr=t.buffer.sampleRate;const data=t.buffer.getChannelData(0);// If selection exists, cut only the selected region +if(selLeft!==null&&selRight!==null&&selRight>selLeft){const trackStart=t.startTime||0;const relSelLeft=Math.max(0,selLeft-trackStart);const relSelRight=Math.max(0,selRight-trackStart);const startSample=Math.floor(relSelLeft*sr);const endSample=Math.min(data.length,Math.floor(relSelRight*sr));const len=endSample-startSample;if(len>0){// Copy selection to clipboard +const ctx=getAudioContext();const clipBuffer=ctx.createBuffer(1,len,sr);clipBuffer.copyToChannel(data.subarray(startSample,endSample),0);clipboardRef.current={buffer:clipBuffer,name:t.name,volumeDb:t.volumeDb,color:t.color};// Remove selection from track, glue the two remaining parts +const newLen=data.length-len;const newBuffer=ctx.createBuffer(1,newLen,sr);const newData=newBuffer.getChannelData(0);let idx=0;for(let i=0;ip.map(tr=>tr.id===selectedTrackId?{...tr,buffer:newBuffer}:tr));const afterSnap=captureTrackSnapshot(selectedTrackId);pushAction('CUT',selectedTrackId,beforeSnap,afterSnap);showToast('Cut selection to clipboard.','info');return;}}// No selection: cut entire track (copy + delete) +handleCopyTrack();handleDeleteTrack();};const handleDeleteTrack=()=>{const tid=selectedTrackId;const sessionTab=sessionTabs.find(s=>s.id===activeTab);if(sessionTab){updateActiveTracks(prev=>prev.filter(t=>t.id!==tid));setSelectedTrackId(activeTracks.filter(t=>t.id!==tid)[0]?.id||'1');}else{const curTracks=tracks;const track=curTracks.find(t=>t.id===tid);if(track&&track.sections&&track.sections.length>0){showToast('Không thể xoá track chứa Section item.','warning');return;}setTracks(p=>p.filter(t=>t.id!==tid));setSelectedTrackId(curTracks.filter(t=>t.id!==tid)[0]?.id||'1');}showToast('Deleted track.','info');};// ── Server Health Check ── +const[viewportWidth,setViewportWidth]=useState(1200);useEffect(()=>{if(!timelineWrapperNode)return;const observer=new ResizeObserver(entries=>{for(let entry of entries){setViewportWidth(entry.contentRect.width);}});observer.observe(timelineWrapperNode);return()=>observer.disconnect();},[timelineWrapperNode]);useEffect(()=>{fetch(API_BASE_URL).then(r=>{if(r.ok)setServerStatus('connected');else setServerStatus('error');}).catch(()=>setServerStatus('offline'));},[]);// ── Computed Values ── +const maxDuration=useMemo(()=>{let max=10;tracks.forEach(t=>{const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default',buffer:t.buffer,startTime:t.startTime||0,name:t.name,speed:t.speed||1.0}]:[];clips.forEach(c=>{if(c.buffer){const cStart=c.startTime||0;const cDur=c.buffer.duration/(c.speed||1.0);max=Math.max(max,cStart+cDur);}});});return max;},[tracks]);const maxDurationRef=useRef(maxDuration);maxDurationRef.current=maxDuration;const minZoom=useMemo(()=>{return viewportWidth/maxDuration;},[viewportWidth,maxDuration]);const timelineWidth=useMemo(()=>{return Math.max(zoom*maxDuration,viewportWidth);},[zoom,maxDuration,viewportWidth]);useEffect(()=>{if(zoomcurrentTime*zoom,[currentTime,zoom]);const selLeft=useMemo(()=>{if(selectionMode==='local'&&localSelectionStart!==null&&localSelectionEnd!==null){return Math.min(localSelectionStart,localSelectionEnd);}if(selectionStart===null||selectionEnd===null)return null;return Math.min(selectionStart,selectionEnd);},[selectionStart,selectionEnd,selectionMode,localSelectionStart,localSelectionEnd]);const selRight=useMemo(()=>{if(selectionMode==='local'&&localSelectionStart!==null&&localSelectionEnd!==null){return Math.max(localSelectionStart,localSelectionEnd);}if(selectionStart===null||selectionEnd===null)return null;return Math.max(selectionStart,selectionEnd);},[selectionStart,selectionEnd,selectionMode,localSelectionStart,localSelectionEnd]);const dspSelectionStats=useMemo(()=>{const track=tracks.find(t=>t.id===selectedTrackId);if(!track)return null;const numChannels=track.buffer?track.buffer.numberOfChannels||1:0;if(selLeft===null||selRight===null||selRight<=selLeft||!track.buffer){return{trackName:track.name,channels:numChannels,timeRange:'Chưa chọn vùng',peakVolume:'N/A'};}const buffer=track.buffer;const sampleRate=buffer.sampleRate;const startSample=Math.max(0,Math.min(buffer.length-1,Math.floor(selLeft*sampleRate)));const endSample=Math.max(0,Math.min(buffer.length,Math.floor(selRight*sampleRate)));let maxVal=0;for(let c=0;cmaxVal)maxVal=val;}}let peakDb='N/A';if(maxVal>0){const db=20*Math.log10(maxVal);peakDb=db.toFixed(2)+' dB';}else{peakDb='-∞ dB';}return{trackName:track.name,channels:numChannels,timeRange:`${selLeft.toFixed(2)}s - ${selRight.toFixed(2)}s (${(selRight-selLeft).toFixed(2)}s)`,peakVolume:peakDb};},[tracks,selectedTrackId,selLeft,selRight]);// ── Toast helper ── +const showToast=(text,type='info',actionText=null,onActionClick=null)=>{if(toastTimeoutRef.current)clearTimeout(toastTimeoutRef.current);setToastMessage({text,type,actionText,onActionClick});toastTimeoutRef.current=setTimeout(()=>setToastMessage(null),actionText?8000:3500);};// ── Server-side upload ── +const uploadToServer=async(file,trackId)=>{const formData=new FormData();formData.append('file',file);try{const resp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!resp.ok)throw new Error(`Upload failed: ${resp.status}`);const data=await resp.json();serverFileIdMap[trackId]=data.file_id;return data;}catch(err){console.warn('Server upload failed, using client-side only:',err.message);return null;}};// ── Server-side waveform loading ── +const loadServerWaveform=async fileId=>{try{const resp=await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`);if(!resp.ok)return null;return await resp.json();}catch{return null;}};// ── Check Celery task result ── +const pollTaskResult=async(taskId,maxPoll=10)=>{for(let i=0;isetTimeout(r,1500));try{const resp=await fetch(`${API_TASKS}/${taskId}`);if(!resp.ok)continue;const data=await resp.json();if(data.status==='SUCCESS')return data.result;if(data.status==='FAILURE')throw new Error(data.error||'Task failed');}catch(err){throw err;}}throw new Error('Task polling timeout');};// ── Wheel Zoom ── +useEffect(()=>{const timeline=timelineWrapperRef.current;if(!timeline)return;const handleWheel=e=>{if(e.ctrlKey||e.metaKey){e.preventDefault();const zoomFactor=e.deltaY>0?0.9:1.1;setZoom(prevZoom=>{let newZoom=prevZoom*zoomFactor;if(newZoom50000)newZoom=50000;requestAnimationFrame(()=>{const centerTime=currentTimeRef.current;const newCenterX=centerTime*newZoom;const viewportWidth=timeline.clientWidth;timeline.scrollLeft=newCenterX-viewportWidth/2;});return newZoom;});}else if(e.shiftKey){e.preventDefault();timeline.scrollLeft+=e.deltaY;}};timeline.addEventListener('wheel',handleWheel,{passive:false});return()=>timeline.removeEventListener('wheel',handleWheel);},[minZoom]);// ── Update Playhead ── +const startSubTabPlayback=(st,offsetWallTime)=>{const context=getAudioContext();const speed=st.speed||1.0;const offsetBuffer=offsetWallTime*speed;const eff=st.buffer.getChannelData(0);const edBuffer=context.createBuffer(1,eff.length,st.buffer.sampleRate);const edData=edBuffer.getChannelData(0);edData.set(eff);const fx=st.effects||{};if(fx.reverse){const reversed=new Float32Array(edData);for(let i=0;i0){const sr=edBuffer.sampleRate;const fadeSamples=Math.min(edData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;i0){const sr=edBuffer.sampleRate;const fadeSamples=Math.min(edData.length,Math.floor(fx.fadeOutMs/1000*sr));for(let i=edData.length-fadeSamples;i0){volumeGainNode.gain.cancelScheduledValues(context.currentTime);volNodes.forEach((n,i)=>{const t=context.currentTime+n.time/speed;const linearGain=Math.pow(10,n.db/20);if(i===0)volumeGainNode.gain.setValueAtTime(linearGain,t);else volumeGainNode.gain.linearRampToValueAtTime(linearGain,t);});}// Schedule panning automation nodes +const panNodes=st.panningNodes||[];if(panNodes.length>0){pannerNode.pan.cancelScheduledValues(context.currentTime);panNodes.forEach((n,i)=>{const t=context.currentTime+n.time/speed;const clamped=Math.max(-1,Math.min(1,n.pan));if(i===0)pannerNode.pan.setValueAtTime(clamped,t);else pannerNode.pan.linearRampToValueAtTime(clamped,t);});}// Schedule fade curves +const duration=st.buffer.duration;const fIn=st.fadeInLen||0;const fOut=st.fadeOutLen||0;if(fIn>0){fadeGainNode.gain.setValueAtTime(0.0,context.currentTime);fadeGainNode.gain.linearRampToValueAtTime(1.0,context.currentTime+fIn/speed);}if(fOut>0){const fadeOutStart=(duration-fOut)/speed;fadeGainNode.gain.setValueAtTime(1.0,context.currentTime+Math.max(0,fadeOutStart));fadeGainNode.gain.linearRampToValueAtTime(0.0,context.currentTime+duration/speed);}source.connect(volumeGainNode);volumeGainNode.connect(pannerNode);pannerNode.connect(fadeGainNode);fadeGainNode.connect(context.destination);source.start(context.currentTime,offsetBuffer);activeSourcesRef.current=[source];activeTrackNodesRef.current[st.trackId]={gainNode:volumeGainNode,pannerNode,source};startOffsetTimeRef.current=offsetWallTime;startBufferOffsetRef.current=offsetBuffer;startAudioTimeRef.current=context.currentTime;};const updatePlayhead=()=>{if(activeTabRef.current!=='main'){const st=subTabsRef.current.find(s=>s.id===activeTabRef.current);if(!st||!st.isPlaying||!st.buffer)return;const context=getAudioContext();const speedFactor=st.speed||1.0;const elapsed=context.currentTime-startAudioTimeRef.current;const wallTime=startOffsetTimeRef.current+elapsed;const bufferPos=startBufferOffsetRef.current+elapsed*speedFactor;// Loop sub-tab selection (bufferPos is buffer-time) +if(st.selectionStart!==null&&st.selectionEnd!==null&&st.selectionStart!==st.selectionEnd){const start=Math.min(st.selectionStart,st.selectionEnd);const end=Math.max(st.selectionStart,st.selectionEnd);if(bufferPos>=end){stopAllPlayback();setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:wallTime,isPlaying:true}:s));startSubTabPlayback(st,wallTime);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}}if(bufferPos>=st.buffer.duration){stopAllPlayback();if(isLoopingSelection){setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:0,isPlaying:true}:s));startSubTabPlayback(st,0);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:0,isPlaying:false}:s));}return;}setSubTabs(prev=>prev.map(s=>s.id===activeTabRef.current?{...s,currentTime:wallTime}:s));animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}if(!isPlaying)return;const context=getAudioContext();const elapsed=context.currentTime-startAudioTimeRef.current;const updatedTime=startOffsetTimeRef.current+elapsed;// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 +// If selection cleared by user, play linearly (don't loop) +if(!selectionCleared&&isLoopingSelection&&selLeft!==null&&selRight!==null){if(selRight>selLeft&&updatedTime>=selRight){if(soloedTrackId!==null||selectionMode==='local'){stopAllPlayback();startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;const soloTid=soloedTrackId!==null?soloedTrackId:localSelectionTrackId;startLocalTrackPlayback(soloTid,selLeft);setCurrentTime(selLeft);setIsPlaying(true);}else{stopAllPlayback();startOffsetTimeRef.current=selLeft;startAudioTimeRef.current=context.currentTime;startTrackPlayback(selLeft);setCurrentTime(selLeft);setIsPlaying(true);}animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}}if(updatedTime>=maxDurationRef.current){if(isLoopingSelection){stopAllPlayback();startOffsetTimeRef.current=0;startAudioTimeRef.current=context.currentTime;startTrackPlayback(0);setCurrentTime(0);setIsPlaying(true);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);return;}stopAllPlayback();setCurrentTime(0);return;}setCurrentTime(updatedTime);animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);};useEffect(()=>{if(isPlaying||subTabs.some(s=>s.isPlaying)){animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}else{cancelAnimationFrame(animationFrameIdRef.current);}return()=>cancelAnimationFrame(animationFrameIdRef.current);},[isPlaying,subTabs,isLoopingSelection,selLeft,selRight,selectionMode,localSelectionTrackId,selectionCleared,activeTab]);// ── Playback ── +const startTrackPlayback=offsetTime=>{const context=getAudioContext();const hasSolo=tracks.some(t=>t.solo)||soloedTrackId!==null;tracks.forEach(track=>{const isPlayable=hasSolo?track.id===soloedTrackId||track.solo:!track.muted;if(!isPlayable)return;// Create persistent gain & panner per track for real-time control +const gainNode=context.createGain();const volDb=track.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gainNode.gain.setValueAtTime(volLinear,context.currentTime);const pannerNode=context.createStereoPanner();pannerNode.pan.setValueAtTime((track.pan??0)/100,context.currentTime);pannerNode.connect(context.destination);activeTrackNodesRef.current[track.id]={gainNode,pannerNode};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];clips.forEach(clip=>{if(!clip.buffer)return;const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);gainNode.connect(pannerNode);const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(offsetTime{const context=getAudioContext();const track=tracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Create persistent gain & panner for real-time control +const gainNode=context.createGain();const volDb=track.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gainNode.gain.setValueAtTime(volLinear,context.currentTime);const pannerNode=context.createStereoPanner();pannerNode.pan.setValueAtTime((track.pan??0)/100,context.currentTime);pannerNode.connect(context.destination);activeTrackNodesRef.current[trackId]={gainNode,pannerNode};clips.forEach(clip=>{if(!clip.buffer)return;const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);gainNode.connect(pannerNode);const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(offsetTime{if(activeTab!=='main'){// Sub-tab playback transport +const st=subTabs.find(s=>s.id===activeTab);if(!st||!st.buffer)return;if(st.isPlaying){stopAllPlayback();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,isPlaying:false}:s));}else{stopAllPlayback();const startOffset=st.currentTime||0;startSubTabPlayback(st,startOffset);setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,isPlaying:true,currentTime:startOffset}:s));}return;}const context=getAudioContext();if(isPlaying){stopAllPlayback();}else{startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(currentTime);setIsPlaying(true);}};handlePlayPauseRef.current=handlePlayPause;const handlePause=()=>{if(isPlaying||subTabs.some(s=>s.isPlaying))stopAllPlayback();};const stopAllPlayback=()=>{activeSourcesRef.current.forEach(src=>{try{src.stop();}catch(e){}});activeSourcesRef.current=[];activeTrackNodesRef.current={};setIsPlaying(false);setSubTabs(prev=>prev.map(s=>({...s,isPlaying:false})));};const handleStop=()=>{stopAllPlayback();if(activeTab!=='main'){setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:0}:s));}else{setCurrentTime(0);}};const handleSubTabResizeMouseDown=e=>{e.preventDefault();e.stopPropagation();const startY=e.clientY;const startHeight=subTabHeight;const handleMouseMove=moveEvent=>{const deltaY=moveEvent.clientY-startY;const newHeight=Math.max(48,Math.min(400,startHeight+deltaY));setSubTabHeight(newHeight);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};// ── Playhead set with seek+play ── +const handlePlayheadSet=(time,shiftKey)=>{localSelectionAnchorRef.current=time;if(isPlaying){// Click during playback: seek to position and continue playing +setCurrentTime(time);stopAllPlayback();setTimeout(()=>{startOffsetTimeRef.current=time;startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);},50);}else{// Normal click: just set playhead +setCurrentTime(time);}};const clearLocalSelection=()=>{setSelectionMode(null);setLocalSelectionTrackId(null);setLocalSelectionStart(null);setLocalSelectionEnd(null);};const handleRulerMouseDown=e=>{if(e.ctrlKey){e.preventDefault();e.stopPropagation();clearLocalSelection();setSelectionMode(null);setSelectionStart(null);setSelectionEnd(null);return;}const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const rawTime=mouseX/zoom;const time=snapValue!=='free'?snapTime(rawTime,snapValue,bpm):rawTime;clearLocalSelection();setSelectionMode('global');rulerDragStartRef.current=time;isDraggingRulerRef.current=true;if(e.shiftKey){e.preventDefault();e.stopPropagation();// Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection +const anchor=rulerAnchorRef.current!==null&&rulerAnchorRef.current!==undefined?rulerAnchorRef.current:selectionStart!==null&&selectionStart!==undefined?selectionStart:currentTime;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);setSelectionStart(selS);setSelectionEnd(selE);}else{rulerAnchorRef.current=time;setSelectionStart(time);setSelectionEnd(time);}handlePlayheadSet(time);};// Global Ruler mousemove is tracked via document listener set up in useEffect +useEffect(()=>{const handleMouseMove=e=>{if(!isDraggingRulerRef.current)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const rawTime=Math.max(0,Math.min(maxDuration,mouseX/zoom));const time=snapValueRef.current!=='free'?snapTime(rawTime,snapValueRef.current,bpmRef.current):rawTime;const anchor=rulerAnchorRef.current??rulerDragStartRef.current??time;setSelectionStart(Math.min(anchor,time));setSelectionEnd(Math.max(anchor,time));};const handleMouseUp=()=>{if(isDraggingRulerRef.current){isDraggingRulerRef.current=false;rulerDragStartRef.current=null;}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,maxDuration]);// ── Track Lane Local Selection Drag ── +const localDragInProgressRef=useRef(false);const localDragTrackRef=useRef(null);const localDragStartTimeRef=useRef(0);const localSelectionAnchorRef=useRef(null);const handleTrackLaneMouseDown=(trackId,time)=>{setSelectedTrackId(trackId);clearLocalSelection();localSelectionAnchorRef.current=time;setSelectionMode('local');setLocalSelectionTrackId(trackId);setLocalSelectionStart(time);setLocalSelectionEnd(time);setSelectionStart(time);setSelectionEnd(time);localDragInProgressRef.current=true;localDragTrackRef.current=trackId;localDragStartTimeRef.current=time;};// Document-level mousemove/mouseup for local selection drag +useEffect(()=>{const handleMouseMove=e=>{if(!localDragInProgressRef.current)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=Math.max(0,Math.min(maxDuration,mouseX/zoom));const anchor=localSelectionAnchorRef.current??localDragStartTimeRef.current;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);setLocalSelectionStart(selS);setLocalSelectionEnd(selE);setSelectionStart(selS);setSelectionEnd(selE);};const handleMouseUp=()=>{if(localDragInProgressRef.current){localDragInProgressRef.current=false;localDragTrackRef.current=null;localDragStartTimeRef.current=0;}};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,maxDuration]);const draggedClipRef=useRef(null);draggedClipRef.current=draggedClip;const hoveredTrackIdRef=useRef(null);hoveredTrackIdRef.current=hoveredTrackId;const captureTrackSnapshotRef=useRef(null);captureTrackSnapshotRef.current=captureTrackSnapshot;const draggedSectionItemRef=useRef(null);draggedSectionItemRef.current=draggedSectionItem;const resizedSectionItemRef=useRef(null);resizedSectionItemRef.current=resizedSectionItem;let clipSeqCounter=0;const nextClipId=()=>`clip_${Date.now()}_${++clipSeqCounter}`;const handleClipDragStart=(trackId,clipId,clickOffset,isDuplicate=false)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const existingClips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];const clip=existingClips.find(c=>c.id===clipId||clipId==='default'&&c.id==='default_'+track.id);if(!clip)return;const beforeSnap=captureTrackSnapshot(trackId);if(isDuplicate){const cloneId=nextClipId();const clone={...clip,id:cloneId,startTime:clip.startTime||0,name:clip.name+' (Copy)'};setTracks(prev=>prev.map(t=>{if(t.id===trackId){const newClips=[...existingClips,clone];return{...t,clips:newClips,buffer:newClips[0].buffer,startTime:newClips[0].startTime,name:newClips[0].name};}return t;}));setDraggedClip({trackId,clipId:cloneId,clickOffset,buffer:clip.buffer,name:clip.name+' (Copy)',beforeSnap,isDuplicate:false});return;}if(!track.clips||track.clips.length===0){setTracks(prev=>prev.map(t=>{if(t.id===trackId){return{...t,clips:existingClips};}return t;}));}setDraggedClip({trackId,clipId:clip.id,clickOffset,buffer:clip.buffer,name:clip.name,beforeSnap,isDuplicate:false});};const stretchedClipRef=useRef(null);stretchedClipRef.current=stretchedClip;const handleClipStretchStart=(trackId,clipId,clickTime)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];const clip=clips.find(c=>c.id===clipId||clipId==='default'&&c.id==='default_'+trackId);if(!clip||!clip.buffer)return;const beforeSnap=captureTrackSnapshot(trackId);if(!track.clips||track.clips.length===0){setTracks(prev=>prev.map(t=>t.id===trackId?{...t,clips}:t));}setStretchedClip({trackId,clipId:clip.id==='default'?'default_'+trackId:clip.id,originalDuration:clip.buffer.duration,startTime:clip.startTime,originalSpeed:clip.speed||1.0,beforeSnap});};const handleSelectionEdgeDragStart=(e,trackId,side)=>{const startX=e.clientX;const initialLeft=Math.min(localSelectionStart,localSelectionEnd);const initialRight=Math.max(localSelectionStart,localSelectionEnd);const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;if(side==='left'){const newLeft=Math.max(0,Math.min(initialRight-0.05,initialLeft+deltaSec));setLocalSelectionStart(newLeft);setLocalSelectionEnd(initialRight);}else{const newRight=Math.max(initialLeft+0.05,Math.min(maxDuration,initialRight+deltaSec));setLocalSelectionStart(initialLeft);setLocalSelectionEnd(newRight);}};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleTrackResizeMouseDown=(e,trackId)=>{e.preventDefault();e.stopPropagation();const startY=e.clientY;const track=tracks.find(t=>t.id===trackId);const startHeight=track?track.height||96:96;const handleMouseMove=moveEvent=>{const deltaY=moveEvent.clientY-startY;const newHeight=Math.max(110,Math.min(300,startHeight+deltaY));setTracks(prev=>prev.map(t=>t.id===trackId?{...t,height:newHeight}:t));};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const deleteTrack=trackId=>{const sessionTab=sessionTabs.find(s=>s.id===activeTab);if(sessionTab){updateActiveTracks(prev=>{const filtered=prev.filter(t=>t.id!==trackId);if(filtered.length>0)setSelectedTrackId(filtered[0].id);return filtered;});}else{const track=tracks.find(t=>t.id===trackId);if(track&&track.sections&&track.sections.length>0){showToast('Không thể xoá track chứa Section item.','warning');return;}const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>{const filtered=prev.filter(t=>t.id!==trackId);if(filtered.length>0)setSelectedTrackId(filtered[0].id);return filtered;});}showToast('Đã xóa track.','info');};useEffect(()=>{const handleMouseMove=e=>{const drag=draggedClipRef.current;if(!drag)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=mouseX/zoom;const rawStart=Math.max(0,time-drag.clickOffset);const newStart=snapTime(rawStart,snapValueRef.current,bpmRef.current);const targetTrackId=hoveredTrackIdRef.current||drag.trackId;setTracks(prev=>prev.map(t=>{// Clear the clip from its previous track if it moved to a new track +if(t.id===drag.trackId&&drag.trackId!==targetTrackId){const updatedClips=(t.clips||[]).filter(c=>c.id!==drag.clipId);return{...t,clips:updatedClips,buffer:updatedClips.length>0?updatedClips[0].buffer:null,startTime:updatedClips.length>0?updatedClips[0].startTime:0,name:updatedClips.length>0?updatedClips[0].name:`Track ${t.id}`};}// Update/set clip on target track +if(t.id===targetTrackId){const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const hasClip=existingClips.some(c=>c.id===drag.clipId);let updatedClips;if(hasClip){updatedClips=existingClips.map(c=>c.id===drag.clipId?{...c,startTime:newStart}:c);}else{updatedClips=[...existingClips,{id:drag.clipId,buffer:drag.buffer,startTime:newStart,name:drag.name}];}return{...t,clips:updatedClips,buffer:updatedClips[0].buffer,startTime:updatedClips[0].startTime,name:updatedClips[0].name};}return t;}));if(drag.trackId!==targetTrackId){setDraggedClip(prev=>({...prev,trackId:targetTrackId}));}};const handleMouseUp=()=>{const drag=draggedClipRef.current;if(!drag)return;const afterSnap=captureTrackSnapshotRef.current(drag.trackId);pushAction('MOVE_CLIP',drag.trackId,drag.beforeSnap,afterSnap);setDraggedClip(null);showToast('Đã di chuyển clip.','success');};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom]);// Document-level mousemove/mouseup for clip stretching +useEffect(()=>{const handleMouseMove=e=>{const stretch=stretchedClipRef.current;if(!stretch)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=mouseX/zoom;const newDuration=Math.max(0.1,time-stretch.startTime);const speedRatio=stretch.originalDuration/newDuration;setTracks(prev=>prev.map(t=>{if(t.id===stretch.trackId){const updatedClips=(t.clips||[]).map(c=>{if(c.id===stretch.clipId){return{...c,speed:speedRatio};}return c;});return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer,startTime:updatedClips[0]?.startTime||0,speed:updatedClips[0]?.speed||1.0};}return t;}));};const handleMouseUp=()=>{const stretch=stretchedClipRef.current;if(!stretch)return;const afterSnap=captureTrackSnapshotRef.current(stretch.trackId);pushAction('STRETCH_CLIP',stretch.trackId,stretch.beforeSnap,afterSnap);setStretchedClip(null);showToast('Đã giãn thời gian clip.','success');};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom]);// ── Section / MIDI Item Drag Start ── +const handleSectionItemDragStart=(trackId,itemType,itemId,clickOffset)=>{setDraggedSectionItem({trackId,itemType,itemId,clickOffset});};// ── Section / MIDI Item Resize Start ── +const handleSectionItemResizeStart=(trackId,itemType,itemId,side,clickTime)=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===trackId);if(!track)return;const items=itemType==='section'?track.sections:track.midiItems;const item=(items||[]).find(it=>it.id===itemId);if(!item)return;const start=itemType==='section'?item.start:item.startTime;setResizedSectionItem({trackId,itemType,itemId,side,originalStart:start,originalDuration:item.duration});};// ── Document-level mousemove/mouseup for Section/MIDI item drag ── +useEffect(()=>{const handleMouseMove=e=>{const drag=draggedSectionItemRef.current;if(!drag)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=mouseX/zoom;const newStart=Math.max(0,time-drag.clickOffset);const targetTrackId=hoveredTrackIdRef.current||drag.trackId;updateActiveTracks(prev=>prev.map(t=>{const items=drag.itemType==='section'?t.sections||[]:t.midiItems||[];const updatedItems=items.filter(it=>it.id!==drag.itemId);if(t.id===targetTrackId){const movedItem=items.find(it=>it.id===drag.itemId);if(movedItem){updatedItems.push(drag.itemType==='section'?{...movedItem,start:newStart}:{...movedItem,startTime:newStart});}else{if(drag.itemType==='section'){updatedItems.push({id:drag.itemId,name:'Section',start:newStart,duration:4,color:'#06b6d4'});}else{updatedItems.push({id:drag.itemId,name:'MIDI Item',startTime:newStart,duration:4,notes:[],color:'#a78bfa'});}}}return drag.itemType==='section'?{...t,sections:updatedItems}:{...t,midiItems:updatedItems};}));if(drag.trackId!==targetTrackId){setDraggedSectionItem(prev=>({...prev,trackId:targetTrackId}));}};const handleMouseUp=()=>{const drag=draggedSectionItemRef.current;if(!drag)return;setDraggedSectionItem(null);showToast(`Đã di chuyển ${drag.itemType==='section'?'section':'MIDI item'}.`,'success');};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,activeTab,sessionTabs]);// ── Document-level mousemove/mouseup for Section/MIDI item resize ── +useEffect(()=>{const handleMouseMove=e=>{const resize=resizedSectionItemRef.current;if(!resize)return;const wrapper=timelineWrapperRef.current;if(!wrapper)return;const rect=wrapper.getBoundingClientRect();const scrollLeft=wrapper.scrollLeft;const mouseX=e.clientX-rect.left+scrollLeft;const time=mouseX/zoom;updateActiveTracks(prev=>prev.map(t=>{if(t.id!==resize.trackId)return t;const items=resize.itemType==='section'?[...(t.sections||[])]:[...(t.midiItems||[])];const idx=items.findIndex(it=>it.id===resize.itemId);if(idx===-1)return t;const item=items[idx];if(resize.side==='left'){const newStart=Math.min(time,resize.originalStart+resize.originalDuration-0.1);const end=resize.originalStart+resize.originalDuration;const newDuration=end-newStart;if(newDuration<0.1)return t;items[idx]=resize.itemType==='section'?{...item,start:newStart,duration:newDuration}:{...item,startTime:newStart,duration:newDuration};}else{const newDuration=Math.max(0.1,time-resize.originalStart);items[idx]={...item,duration:newDuration};}return resize.itemType==='section'?{...t,sections:items}:{...t,midiItems:items};}));};const handleMouseUp=()=>{const resize=resizedSectionItemRef.current;if(!resize)return;setResizedSectionItem(null);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);return()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};},[zoom,activeTab,sessionTabs]);const handleSelectRange=(start,end,reset)=>{const maxLen=maxDuration;const cleanStart=Math.max(0,Math.min(maxLen,start));const cleanEnd=Math.max(0,Math.min(maxLen,end));if(reset){setSelectionStart(cleanStart);setSelectionEnd(cleanEnd);}else{setSelectionEnd(cleanEnd);}// LOOP_EDITOR_2.md §4.2: new selection = enable looping +setSelectionCleared(false);};const handleSelectionInputChange=(field,val)=>{const numericVal=Math.max(0,parseFloat(val)||0);if(selectionMode==='local'){// Editing local selection directly +if(field==='start'){setLocalSelectionStart(numericVal);}else{setLocalSelectionEnd(numericVal);}}else{if(field==='start'){setSelectionStart(numericVal);}else{setSelectionEnd(numericVal);}}};const selectionStats=useMemo(()=>{if(selLeft===null||selRight===null){return{start:0,end:0,length:0};}const s=Math.min(selLeft,selRight);const e=Math.max(selLeft,selRight);return{start:parseFloat(s.toFixed(3)),end:parseFloat(e.toFixed(3)),length:parseFloat((e-s).toFixed(3))};},[selLeft,selRight]);// ── Handle Drag (selection resize) ── +const handleHandleDragStart=(e,side)=>{e.preventDefault();e.stopPropagation();const startX=e.clientX;const useLocal=selectionMode==='local';const currentStart=useLocal?localSelectionStart:selectionStart;const currentEnd=useLocal?localSelectionEnd:selectionEnd;const initialLeft=Math.min(currentStart,currentEnd);const initialRight=Math.max(currentStart,currentEnd);const setStart=useLocal?setLocalSelectionStart:setSelectionStart;const setEnd=useLocal?setLocalSelectionEnd:setSelectionEnd;const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;if(side==='left'){const newLeft=Math.max(0,Math.min(initialRight-0.05,initialLeft+deltaSec));setStart(newLeft);setEnd(initialRight);}else{const newRight=Math.max(initialLeft+0.05,Math.min(maxDuration,initialRight+deltaSec));setStart(initialLeft);setEnd(newRight);}};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleSelectionBodyDragStart=e=>{e.preventDefault();e.stopPropagation();const startX=e.clientX;const useLocal=selectionMode==='local';const currentStart=useLocal?localSelectionStart:selectionStart;const currentEnd=useLocal?localSelectionEnd:selectionEnd;const initialLeft=Math.min(currentStart,currentEnd);const initialRight=Math.max(currentStart,currentEnd);const widthSec=initialRight-initialLeft;const setStart=useLocal?setLocalSelectionStart:setSelectionStart;const setEnd=useLocal?setLocalSelectionEnd:setSelectionEnd;const handleMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const deltaSec=deltaX/zoom;let newLeft=initialLeft+deltaSec;let newRight=initialRight+deltaSec;if(newLeft<0){newLeft=0;newRight=widthSec;}if(newRight>maxDuration){newRight=maxDuration;newLeft=maxDuration-widthSec;}setStart(newLeft);setEnd(newRight);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};// ── Track Controls ── +const toggleTrackSoloEvaluate=trackId=>{const wasPlaying=isPlaying;stopAllPlayback();setTracks(prev=>prev.map(t=>{if(t.id===trackId){return{...t,solo:!t.solo,muted:false};}return{...t,solo:false};}));setSoloedTrackId(prev=>prev===trackId?null:trackId);if(wasPlaying){setTimeout(()=>{startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=getAudioContext().currentTime;setIsPlaying(true);},50);}};const toggleTrackMute=trackId=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,muted:!t.muted}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'MUTE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateTrackVolumeDb=(trackId,val)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,volumeDb:val}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'VOLUME_CHANGE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});// Real-time update during playback +const nodes=activeTrackNodesRef.current[trackId];if(nodes){const volLinear=val<=-50?0:Math.pow(10,val/20);nodes.gainNode.gain.setValueAtTime(volLinear,getAudioContext().currentTime);}};const updateTrackPan=(trackId,val)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,pan:val}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'PAN_CHANGE',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});// Real-time update during playback +const nodes=activeTrackNodesRef.current[trackId];if(nodes){nodes.pannerNode.pan.setValueAtTime(val/100,getAudioContext().currentTime);}};const updateTrackName=(trackId,name)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,name}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'RENAME',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateTrackColor=(trackId,color)=>{const beforeSnap=captureTrackSnapshot(trackId);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,color}:t));setUndoStack(prev=>{const next=[...prev,{action_type:'RECOLOR',track_id:trackId,timestamp:Date.now(),before_state:beforeSnap,after_state:captureTrackSnapshot(trackId)}];if(next.length>MAX_UNDO)next.shift();return next;});};const updateClipName=(trackId,clipId,newName)=>{setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const clips=t.clips&&t.clips.length>0?t.clips:[];return{...t,clips:clips.map(c=>c.id===clipId?{...c,name:newName}:c)};}));};// ── Load File on Track (with server upload) ── +const loadFileOnTrack=async(trackId,file)=>{if(!file)return;showToast(`Đang nạp file ${file.name}...`,'info');try{// Upload to server +uploadToServer(file,trackId);// Decode locally for playback + analyze channels (stereo/mono) +const{audioBuffer:decodedBuffer,channelInfo}=await window.SonicAudio.decodeAudioFile(file);setTracks(prev=>prev.map(t=>t.id===trackId?{...t,name:file.name,buffer:decodedBuffer,channelInfo:channelInfo}:t));showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`,'success');}catch(err){showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.",'error');}};// ── Synth Generators ── +const generateSynthToTrack=(trackId,type)=>{const sampleRate=44100;const duration=12.0;const context=getAudioContext();const frameCount=sampleRate*duration;const newBuffer=context.createBuffer(1,frameCount,sampleRate);const channelData=newBuffer.getChannelData(0);if(type==='kick'){for(let i=0;iprev.map(t=>t.id===trackId?{...t,name:`Demo_${type.toUpperCase()}.wav`,buffer:newBuffer}:t));showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`,'success');};// ── Add Track ── +const addNewTrack=()=>{const curTracks=activeTracks;const newId=(curTracks.length+1).toString();const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[curTracks.length%colors.length];updateActiveTracks(prev=>[...prev,{id:newId,name:`Track ${newId}`,buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]}]);showToast(`Đã thêm Track ${newId}.`,'info');setTimeout(()=>lucide.createIcons(),200);return newId;};// ── Update tracks in active context (main session or section tab) ── +const updateActiveTracks=updater=>{const sessionTab=sessionTabs.find(s=>s.id===activeTab);if(sessionTab){setSessionTabs(prev=>prev.map(st=>st.id===activeTab?{...st,tracks:updater(st.tracks)}:st));}else{setTracks(updater);}};// ── Insert Track Below Selected ── +const insertTrackBelow=()=>{const curTracks=activeTracks;const newId=`t${Date.now()}`;const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[curTracks.length%colors.length];const newTrack={id:newId,name:`Track ${newId}`,buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]};updateActiveTracks(prev=>{const idx=prev.findIndex(t=>t.id===selectedTrackId);if(idx===-1)return[...prev,newTrack];const copy=[...prev];copy.splice(idx+1,0,newTrack);return copy;});setSelectedTrackId(newId);showToast(`Đã thêm Track ${newId}.`,'info');setTimeout(()=>lucide.createIcons(),200);};// ── Insert Section at Playhead ── +const insertSectionAtPlayhead=()=>{const track=tracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const section={id:`sec_${Date.now()}`,name:'Section',start:currentTime,duration:4,color:track.color||'#06b6d4'};setTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,sections:[...(t.sections||[]),section]}:t));showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`,'success');};// ── Insert MIDI Item at Playhead ── +const insertMidiItemAtPlayhead=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const midiItem={id:`midi_${Date.now()}`,name:'MIDI Item',startTime:currentTime,duration:4,notes:[],color:'#a78bfa'};updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,midiItems:[...(t.midiItems||[]),midiItem]}:t));showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`,'success');};// ── Insert Sound Clip at Cursor ── +const insertSoundClipAtCursor=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const input=document.createElement('input');input.type='file';input.accept='audio/*';input.multiple=true;input.onchange=async e=>{const files=Array.from(e.target.files||[]);if(!files.length)return;let offset=currentTime;let count=0;for(const file of files){try{uploadToServer(file,track.id);const{audioBuffer:decodedBuffer,channelInfo}=await window.SonicAudio.decodeAudioFile(file);const clipId=`clip_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;const newClip={id:clipId,buffer:decodedBuffer,startTime:offset,name:file.name,speed:1.0};updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,clips:[...(t.clips||[]),newClip]}:t));offset+=decodedBuffer.duration;count++;}catch(err){showToast(`Lỗi nạp ${file.name}`,'error');}}if(count>0)showToast(`Đã chèn ${count} file âm thanh`,'success');};input.click();};// ── Server-side Export ── +const triggerWavExport=async()=>{let exportTracks;let clipStart=0;let clipEnd=0;const src=exportSettings.source;if(src==='active_clip'||src==='clip_selection'){const selTrack=selectedTrackId?tracks.find(t=>t.id===selectedTrackId):null;if(!selTrack||!selTrack.buffer){showToast("Không có clip nào được chọn.","warning");return;}let rangeStart=selectionStart;let rangeEnd=selectionEnd;if(rangeStart===null||rangeEnd===null||rangeEnd<=rangeStart){rangeStart=0;rangeEnd=selTrack.buffer.duration;}const ctx=getAudioContext();const numCh=selTrack.buffer.numberOfChannels||1;const sr=selTrack.buffer.sampleRate;const startSample=Math.max(0,Math.floor(rangeStart*sr));const endSample=Math.min(selTrack.buffer.length,Math.floor(rangeEnd*sr));const len=endSample-startSample;if(len<=100){showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.","warning");return;}const clipBuf=ctx.createBuffer(numCh,len,sr);for(let ch=0;cht.buffer&&!t.muted);const selTrk=selectedTrackId?sel.filter(t=>t.id===selectedTrackId):sel;if(selTrk.length===0){showToast("Track được chọn không có dữ liệu.","warning");return;}exportTracks=selTrk;}else{exportTracks=tracks.filter(t=>t.buffer&&!t.muted);}if(exportTracks.length===0){showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.","warning");return;}// Check if all active tracks have server file IDs +const allOnServer=exportTracks.every(t=>serverFileIdMap[t.id]);if(allOnServer&&serverStatus==='connected'){// Use server-side export +setIsExporting(true);showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...","info");try{const sessionId=`session_${Date.now()}`;const tracksMeta=exportTracks.map(t=>({track_id:t.id,file_id:serverFileIdMap[t.id],volume_db:t.volumeDb,muted:false,clips:[{clip_id:`clip_${t.id}`,start_time_seconds:t.startTime||0,end_time_seconds:(t.startTime||0)+t.buffer.duration,loop_count:1,apply_zero_crossing:true,fade_in_ms:0,fade_out_ms:0}]}));const resp=await fetch(`${API_MULTITRACK}/mix`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:sessionId,export_settings:{sample_rate:parseInt(exportSettings.sampleRate),bit_depth:parseInt(exportSettings.bitDepth),format:exportSettings.format,channels:exportSettings.channels},tracks:tracksMeta})});if(!resp.ok)throw new Error(`Server export failed: ${resp.status}`);const data=await resp.json();showToast("Đang xử lý trên máy chủ...","info");// Poll for result +const result=await pollTaskResult(data.task_id,20);if(result.success){// Get the uploaded file from server +const fileId=result.output_file_id||result.output_path?.split('/').pop();if(fileId){const downloadUrl=`${API_AUDIO}/download/${fileId}`;showToast("Xuất bản âm thanh từ máy chủ hoàn tất!","success","Tải về",()=>{const a=document.createElement('a');a.href=downloadUrl;a.download=fileId;a.click();});}}else{throw new Error(result.error||'Server processing failed');}}catch(err){showToast("Lỗi xuất máy chủ: "+err.message+". Chuyển sang xuất client.","warning");// Fall back to client-side export +clientSideExport(exportTracks);}finally{setIsExporting(false);}}else{// Client-side export (existing working code) +clientSideExport(exportTracks);}};const handleSaveProject=async()=>{if(!currentUser){setIsMandatoryLogin(false);setAuthMode('login');setAuthModalOpen(true);return;}if(!projectName){setSaveProjectModalOpen(true);return;}const projectSchemaObj=serializeProjectToSchema(currentProjectId||'project_'+Date.now(),projectName,bpm,tracks,subTabs,sessionTabs);const dataJson=JSON.stringify(projectSchemaObj);try{if(currentProjectId){await window.SonicAPI.updateCloudProject(currentProjectId,projectName,dataJson);showToast(`Đã cập nhật dự án "${projectName}" lên server!`,"success");}else{const res=await window.SonicAPI.saveCloudProject(projectName,dataJson);const newProjId=res.project_id;if(newProjId){setCurrentProjectId(newProjId);localStorage.setItem('sonic_project_id',newProjId);}showToast(`Đã lưu dự án "${projectName}" mới lên server!`,"success");}}catch(err){showToast(err.message||"Lỗi lưu server","error");}};const handleSaveProjectWithName=async newName=>{setProjectName(newName);localStorage.setItem('sonic_project_name',newName);const projectSchemaObj=serializeProjectToSchema('project_'+Date.now(),newName,bpm,tracks,subTabs,sessionTabs);const dataJson=JSON.stringify(projectSchemaObj);try{const res=await window.SonicAPI.saveCloudProject(newName,dataJson);const newProjId=res.project_id;if(newProjId){setCurrentProjectId(newProjId);localStorage.setItem('sonic_project_id',newProjId);}showToast(`Đã lưu dự án "${newName}" mới lên server!`,"success");}catch(err){showToast(err.message||"Lỗi lưu server","error");}};const handleSaveAsCloud=async newName=>{const projectSchemaObj=serializeProjectToSchema('project_'+Date.now(),newName,bpm,tracks,subTabs,sessionTabs);const dataJson=JSON.stringify(projectSchemaObj);try{const res=await window.SonicAPI.saveCloudProject(newName,dataJson);const newProjId=res.project_id;setProjectName(newName);localStorage.setItem('sonic_project_name',newName);if(newProjId){setCurrentProjectId(newProjId);localStorage.setItem('sonic_project_id',newProjId);}showToast(`Đã lưu dự án dưới tên mới "${newName}" lên server!`,"success");}catch(err){showToast(err.message||"Lỗi Save As lên server","error");}};const handleSaveCloud=handleSaveProject;const handleExportSFS=(customName=null)=>{const finalName=customName||projectName||'Dự án SonicForge';const projectSchemaObj=serializeProjectToSchema(currentProjectId||'proj_'+Date.now(),finalName,bpm,tracks,subTabs,sessionTabs);window.SonicStorage.exportProjectToSFS(projectSchemaObj);showToast("Đã xuất dự án (.sfs) thành công!","success");};const handleImportSFS=()=>{const input=document.createElement('input');input.type='file';input.accept='.sfs,application/json';input.onchange=async e=>{if(!e.target.files[0])return;try{const proj=await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]);const restored=(proj.tracks||[]).map(t=>({...t,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null}));if(restored.length>0){setTracks(restored);setProjectName(proj.name||'Dự án mới');setCurrentProjectId(null);localStorage.setItem('sonic_project_name',proj.name||'Dự án mới');localStorage.removeItem('sonic_project_id');showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`,"success");}}catch(err){showToast(err.message||"Lỗi mở tệp .sfs","error");}};input.click();};const clientSideExport=async activeTracks=>{setIsExporting(true);showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...","info");try{const targetRate=parseInt(exportSettings.sampleRate);const bitDepth=parseInt(exportSettings.bitDepth);const durationLimit=Math.max(...activeTracks.map(t=>{const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default',buffer:t.buffer,startTime:t.startTime||0,name:t.name,speed:t.speed||1.0}]:[];if(clips.length===0)return 0;return Math.max(...clips.map(c=>(c.startTime||0)+c.buffer.duration/(c.speed||1.0)));}));const outChannels=exportSettings.channels==='mono'?1:2;const offlineCtx=new OfflineAudioContext(outChannels,Math.ceil(targetRate*Math.max(0.1,durationLimit)),targetRate);activeTracks.forEach(t=>{const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default',buffer:t.buffer,startTime:t.startTime||0,name:t.name,speed:t.speed||1.0}]:[];clips.forEach(clip=>{if(!clip.buffer)return;const source=offlineCtx.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;const gain=offlineCtx.createGain();const volDb=t.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gain.gain.setValueAtTime(volLinear,0);const panner=offlineCtx.createStereoPanner();panner.pan.setValueAtTime((t.pan??0)/100,0);source.connect(gain);gain.connect(panner);panner.connect(offlineCtx.destination);const clipStart=clip.startTime||0;source.start(clipStart);});});const renderedBuffer=await offlineCtx.startRendering();const numExportCh=renderedBuffer.numberOfChannels;const exportLength=renderedBuffer.length;const bytesPerSample=bitDepth/8;const headerSize=44;const fileSizeBytes=headerSize+exportLength*bytesPerSample*numExportCh;const fileBuffer=new ArrayBuffer(fileSizeBytes);const view=new DataView(fileBuffer);const writeString=(offset,string)=>{for(let i=0;i>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}offset+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});const url=URL.createObjectURL(blob);if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",()=>{const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{const a=document.createElement('a');a.href=url;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;a.click();});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}const a=document.createElement('a');a.href=url;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;a.click();showToast("Xuất bản âm thanh hoàn tất!","success");}URL.revokeObjectURL(url);}catch(err){showToast("Lỗi xuất âm thanh: "+err.message,"error");}finally{setIsExporting(false);}};// ── AI Analysis (server-side with fallback) ── +const triggerAIAnalysis=async()=>{setAnalysisState({status:'Connecting to AI Engine...',data:null,isRunning:true});// Check if we have a file on the server to analyze +const activeTrack=tracks.find(t=>t.id===selectedTrackId);const serverFileId=serverFileIdMap[selectedTrackId];if(serverFileId&&serverStatus==='connected'){try{const resp=await fetch(`${API_AUDIO}/analyze-ai`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:serverFileId,api_base_url:aiConfig.baseUrl!==API_BASE_URL?aiConfig.baseUrl:null,model:aiConfig.model})});if(resp.ok){const data=await resp.json();showToast("Đang phân tích cấu trúc trên máy chủ...","info");try{const result=await pollTaskResult(data.task_id,15);if(result.bpm){if(selLeft!==null&&selRight!==null&&activeTrack&&activeTrack.buffer){const snapStart=findZeroCrossing(activeTrack.buffer,selLeft);const snapEnd=findZeroCrossing(activeTrack.buffer,selRight);if(selectionMode==='local'){setLocalSelectionStart(snapStart);setLocalSelectionEnd(snapEnd);}else{setSelectionStart(snapStart);setSelectionEnd(snapEnd);}}setAnalysisState({status:'Hoàn thành phân tích (Server)',data:result,isRunning:false});showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length||0} beats detected (Zero-Crossing Aligned)`,'success');return;}}catch(e){console.warn('Server AI poll failed, using fallback:',e);}}}catch(err){console.warn('Server AI analysis failed, using fallback:',err);}}// Fallback: client-side analysis with simulated BPM +setAnalysisState({status:'Processing structural detection...',data:null,isRunning:true});setTimeout(()=>{let bpm=120;// Try to detect BPM from buffer +if(activeTrack&&activeTrack.buffer){const data=activeTrack.buffer.getChannelData(0);const sr=activeTrack.buffer.sampleRate;// Simple autocorrelation for BPM estimation +const windowSize=Math.min(sr*3,data.length);if(windowSize>sr){let maxCorr=0;let bestLag=Math.floor(sr*0.25);// ~240 BPM max +for(let lag=Math.floor(sr*0.25);lagmaxCorr){maxCorr=corr;bestLag=lag;}}if(bestLag>0){bpm=Math.round(60*sr/bestLag);bpm=Math.max(60,Math.min(200,bpm));}}}if(selLeft!==null&&selRight!==null&&activeTrack&&activeTrack.buffer){const snapStart=findZeroCrossing(activeTrack.buffer,selLeft);const snapEnd=findZeroCrossing(activeTrack.buffer,selRight);if(selectionMode==='local'){setLocalSelectionStart(snapStart);setLocalSelectionEnd(snapEnd);}else{setSelectionStart(snapStart);setSelectionEnd(snapEnd);}}setAnalysisState({status:'Hoàn thành phân tích (Client)',data:{bpm,bars:Math.max(4,Math.round(bpm/30)),timeSig:'4/4',detectedKey:'Am'},isRunning:false});showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`,'success');},1500);};// ── AI Analysic Loop: scan track, detect beats, place markers for loop selection ── +const handleAIAnalysicLoop=async()=>{const forcedTrackId=subTabAiTrackIdRef.current;subTabAiTrackIdRef.current=null;const activeTrackId=forcedTrackId||selectedTrackId;const activeTrack=tracks.find(t=>t.id===activeTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.","warning");return;}setAnalysisState({status:'AI Analysic Loop: đang phát hiện nhịp...',data:null,isRunning:true});showToast("AI Analysic Loop: đang quét cấu trúc nhịp điệu...","info");try{const buffer=activeTrack.buffer;const data=buffer.getChannelData(0);const sr=buffer.sampleRate;const windowSize=Math.min(sr*3,data.length);// Client-side BPM detection via autocorrelation +let detectedBPM=120;if(windowSize>sr){let maxCorr=0;for(let lag=Math.floor(sr*0.3);lag<=Math.floor(sr*2.0);lag++){let corr=0;const step=4;for(let i=0;imaxCorr){maxCorr=corr;detectedBPM=60/(lag/sr);}}}detectedBPM=Math.round(Math.min(240,Math.max(60,detectedBPM)));const beatDuration=60/detectedBPM;const barDuration=beatDuration*4;const totalDuration=buffer.duration;// Place markers at each bar (strong beat) position +const barMarkers=[];for(let t=0;tprev.map(t=>{if(t.id!==activeTrack.id)return t;const existingMarkers=t.markers||[];return{...t,markers:[...existingMarkers,...barMarkers]};}));// Find the first strong beat to set as selection start +const firstBeat=barMarkers.length>0?barMarkers[0].time:0;const secondBar=barMarkers.length>4?barMarkers[Math.min(4,barMarkers.length-1)].time:Math.min(totalDuration,firstBeat+barDuration);setSelectionStart(firstBeat);setSelectionEnd(secondBar);setAnalysisState({status:`AI Analysic Loop: ${detectedBPM} BPM, ${barMarkers.length} markers (Bar/Beat)`,data:{bpm:detectedBPM,bars:Math.floor(totalDuration/barDuration)},isRunning:false});showToast(`AI Analysic Loop: ${detectedBPM} BPM - ${Math.floor(totalDuration/barDuration)} bars detected`,"success");}catch(err){setAnalysisState({status:'Lỗi AI Analysic Loop',data:null,isRunning:false});showToast(err.message||'Lỗi khi phân tích nhịp','error');}};// ── Mark Selection ── +// ── Helper: set selection range from buffer (used by sub-tab AI) ── +const setSelectionRangeOnBuffer=(buffer,startTime,endTime)=>{setSelectionStart(startTime);setSelectionEnd(endTime);};// Ref for forced trackId (used by sub-tab AI buttons, overrides selectedTrackId) +const subTabAiTrackIdRef=useRef(null);const handleAIScan=async()=>{// Resolve track: prefer sub-tab override, then selectedTrackId +const forcedTrackId=subTabAiTrackIdRef.current;subTabAiTrackIdRef.current=null;const activeTrackId=forcedTrackId||selectedTrackId;const activeTrack=tracks.find(t=>t.id===activeTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track có âm thanh để AI quét Loop.","warning");return;}setAnalysisState({status:'AI Loop Scan đang quét nhịp điệu và phách mạnh...',data:null,isRunning:true});showToast("AI Scan đang phân tích tempo và phách mạnh...","info");try{const buffer=activeTrack.buffer;const data=buffer.getChannelData(0);const sr=buffer.sampleRate;const windowSize=Math.min(sr*3,data.length);// Detect BPM via autocorrelation +let detectedBPM=120;if(windowSize>sr){let maxCorr=0;for(let lag=Math.floor(sr*0.3);lag<=Math.floor(sr*2.0);lag++){let corr=0;const step=4;for(let i=0;imaxCorr){maxCorr=corr;detectedBPM=60/(lag/sr);}}}detectedBPM=Math.round(Math.min(240,Math.max(60,detectedBPM)));// Calculate bar grid +const beatDuration=60/detectedBPM;const barDuration=beatDuration*4;const totalDuration=buffer.duration;// Place markers at bar starts (strong beats / downbeats) +const barMarkers=[];for(let t=0;tprev.map(t=>{if(t.id!==activeTrack.id)return t;const existingMarkers=t.markers||[];return{...t,markers:[...existingMarkers,...barMarkers]};}));// Set selection to the first downbeat +const firstBarStart=barMarkers.length>0?barMarkers[0].time:0;const secondBarStart=barMarkers.length>1?barMarkers[1].time:Math.min(totalDuration,firstBarStart+barDuration);setSelectionStart(firstBarStart);setSelectionEnd(secondBarStart);setAnalysisState({status:`AI Scan: ${detectedBPM} BPM, ${barMarkers.length} downbeats (đã snap zero-crossing)`,data:{bpm:detectedBPM},isRunning:false});showToast(`AI Scan: ${detectedBPM} BPM - ${barMarkers.length} downbeats detected`,"success");}catch(err){setAnalysisState({status:'Lỗi khi AI Scan',data:null,isRunning:false});showToast(err.message||'Lỗi khi quét AI Loop','error');}};const runPythonTool=async toolType=>{const activeTrack=tracks.find(t=>t.id===selectedTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track để xử lý công cụ Python.","warning");return;}try{if(toolType==='normalize'){const channelData=activeTrack.buffer.getChannelData(0);let maxVal=0;for(let i=0;i0){const gain=1.0/maxVal;for(let i=0;i=2){const left=activeTrack.buffer.getChannelData(0);const right=activeTrack.buffer.getChannelData(1);for(let i=0;i{if(selLeft===null||selRight===null||selectionStats.length===0){showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.","warning");return;}const targetTrack=tracks.find(t=>t.id===selectedTrackId);if(!targetTrack||!targetTrack.buffer){showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.","warning");return;}// For local selection, force mark on the local-selected track +const markTrackId=selectionMode==='local'&&localSelectionTrackId?localSelectionTrackId:selectedTrackId;setTracks(prev=>prev.map(t=>{if(t.id!==markTrackId)return t;const snapStart=findZeroCrossing(t.buffer,selLeft);const snapEnd=findZeroCrossing(t.buffer,selRight);const newMarkers=[...t.markers,{id:Date.now()+'_s',time:snapStart},{id:Date.now()+'_e',time:snapEnd}].sort((a,b)=>a.time-b.time);return{...t,markers:newMarkers};}));showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`,"success");};// ── AI Cut to New Track (Music Theory Loop Detection) ── +const handleAICutToNewTrack=()=>{const forcedTrackId=subTabAiTrackIdRef.current;subTabAiTrackIdRef.current=null;const activeTrackId=forcedTrackId||selectedTrackId;const activeTrack=tracks.find(t=>t.id===activeTrackId);if(!activeTrack||!activeTrack.buffer){showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.","warning");return;}if(selectionStart===null||selectionEnd===null||selectionStats.length===0){showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.","warning");return;}setAnalysisState({status:'AI Cut: đang phân tích nhịp và tìm loop point...',data:null,isRunning:true});showToast("AI Cut: đang phân tích nhịp điệu và tìm điểm loop chính xác...","info");setTimeout(()=>{try{const buffer=activeTrack.buffer;const sampleRate=buffer.sampleRate;const channelData=buffer.getChannelData(0);const dataLen=channelData.length;const windowSize=Math.min(sampleRate*3,dataLen);// Detect BPM via autocorrelation +let detectedBPM=120;if(windowSize>sampleRate){let maxCorr=0;for(let lag=Math.floor(sampleRate*0.3);lag<=Math.floor(sampleRate*2.0);lag++){let corr=0;const step=4;for(let i=0;imaxCorr){maxCorr=corr;detectedBPM=60/(lag/sampleRate);}}}detectedBPM=Math.round(Math.min(240,Math.max(60,detectedBPM)));const beatDuration=60/detectedBPM;const barDuration=beatDuration*4;// Use the selection range +const rawStart=selectionStats.start;const rawEnd=selectionStats.end;const selDuration=rawEnd-rawStart;// Find the nearest bar start (downbeat) for loop start +const barsFromZero=rawStart/barDuration;const nearestBarStart=Math.round(barsFromZero)*barDuration;const loopStart=Math.max(0,Math.min(rawStart+barDuration,nearestBarStart));// Find the nearest beat 4 (bar end) for loop end +// In 4/4 time: beat 4 = barStart + 3*beatDuration = barEnd +const barsFromStart=rawEnd/barDuration;const nearestBarEnd=Math.round(barsFromStart)*barDuration;// Ensure minimum 1 bar loop +let loopEnd=Math.max(loopStart+barDuration,nearestBarEnd);if(loopEnd>rawEnd+beatDuration)loopEnd=loopStart+Math.ceil(selDuration/barDuration)*barDuration;// Snap to zero-crossing for click-free loop +const snapLoopStart=findZeroCrossing(buffer,loopStart);const snapLoopEnd=findZeroCrossing(buffer,loopEnd);// Place markers for the loop points +setTracks(prev=>prev.map(t=>{if(t.id!==activeTrack.id)return t;const existingMarkers=t.markers||[];const filtered=existingMarkers.filter(m=>!m.id.startsWith('ai_loop_'));return{...t,markers:[...filtered,{id:'ai_loop_start_'+Date.now(),time:snapLoopStart,label:'Loop Start (Bar '+(Math.floor(snapLoopStart/barDuration)+1)+')',color:'#06b6d4'},{id:'ai_loop_end_'+Date.now(),time:snapLoopEnd,label:'Loop End (Beat 4)',color:'#a855f7'}]};}));setSelectionStart(snapLoopStart);setSelectionEnd(snapLoopEnd);const startSample=Math.max(0,Math.min(dataLen-1,Math.floor(snapLoopStart*sampleRate)));const endSample=Math.max(0,Math.min(dataLen,Math.floor(snapLoopEnd*sampleRate)));const sliceLength=endSample-startSample;if(sliceLength<=0){showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.","error");setAnalysisState({status:'Thất bại',data:null,isRunning:false});return;}const context=getAudioContext();const numChannels=buffer.numberOfChannels||1;const slicedBuffer=context.createBuffer(numChannels,sliceLength,sampleRate);for(let c=0;c{const idx=prev.findIndex(t=>t.id===selectedTrackId);const updated=[...prev];if(idx!==-1){updated.splice(idx+1,0,newTrack);}else{updated.push(newTrack);}return updated;});setSelectedTrackId(newId);setAnalysisState({status:`AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`,data:{bpm:detectedBPM,bars:barsCount,timeSig:'4/4'},isRunning:false});showToast(`AI Cut: ${barsCount} bars loop at ${snapLoopStart.toFixed(3)}s - ${snapLoopEnd.toFixed(3)}s [${detectedBPM} BPM]`,"success");setTimeout(()=>lucide.createIcons(),200);}catch(err){showToast("Lỗi khi AI Cut: "+err.message,"error");setAnalysisState({status:'Lỗi AI Cut',data:null,isRunning:false});}},800);};// ── AI Prompt Send to Active Provider ── +const handleAISend=async()=>{const prompt=aiPrompt.trim();if(!prompt){showToast('Vui lòng nhập nội dung prompt.','warning');return;}if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentSelectedTrackId=selectedTrackId;window.DAWCommandDispatcher.currentTracks=tracks;window.DAWCommandDispatcher.lastCutSourceTrackId=null;window.DAWCommandDispatcher.lastCutNewTrackId=null;}setAiProcessing(true);setAiActionLog(prev=>[...prev,{type:'status',text:` ⏳ Đang gửi prompt đến AI...`,time:Date.now()}]);try{if(aiProviders.length===0||!selectedProviderId){try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers&&data.providers.length>0){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){}}const prv=aiProviders.find(p=>p.id===selectedProviderId)||(aiProviders.length>0?aiProviders[0]:null);const provider=prv||aiConfig;const baseUrl=provider.api_base_url||provider.baseUrl||`${API_BASE_URL}`;const apiKey=provider.api_key||provider.apiKey||'';const model=provider.model_name||provider.model||'deepseek-chat';setAiActionLog(prev=>[...prev,{type:'info',text:` Provider: ${provider.name||'default'} | Model: ${model} | URL: ${baseUrl.slice(0,40)}`,time:Date.now()}]);const dawContext=window.AIGateway.buildAIPromptContext({tracks,bpm,selectedTrackId,currentTime,selLeft,selRight});const result=await window.AIGateway.executeAIPrompt({prompt:prompt,provider:provider.name||'default',model:model,apiKey:apiKey,baseUrl:baseUrl.replace(/\/chat\/completions$/,'').replace(/\/$/,''),dawContext,tools:window.AIGateway.DEFAULT_TOOLS});const hasText=!!result.textResponse;const hasCalls=result.functionCalls&&result.functionCalls.length>0;if(hasText){setAiActionLog(prev=>[...prev,{type:'status',text:` AI: ${result.textResponse.slice(0,500)}`,time:Date.now()}]);}if(hasCalls){setAiActionLog(prev=>[...prev,{type:'info',text:` Gọi ${result.functionCalls.length} lệnh...`,time:Date.now()}]);if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.isExecutingAI=true;}try{for(const fc of result.functionCalls){setAiActionLog(prev=>[...prev,{type:'info',text:` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`,time:Date.now()}]);const cmdName=fc.name.toUpperCase();if(window.DAWCommandDispatcher){try{let cmdResult=window.DAWCommandDispatcher.execute(cmdName,fc.arguments);if(cmdResult&&typeof cmdResult.then==='function')cmdResult=await cmdResult;setAiActionLog(prev=>[...prev,{type:'status',text:` ✅ ${fc.name}: ${cmdResult&&cmdResult.success?'thành công':'thất bại: '+(cmdResult&&cmdResult.error||'unknown')}`,time:Date.now()}]);}catch(cmdErr){setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ ${fc.name}: ${cmdErr.message}`,time:Date.now()}]);}}else{setAiActionLog(prev=>[...prev,{type:'error',text:` ❌ ${fc.name}: DAWCommandDispatcher not available`,time:Date.now()}]);}}}finally{if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.isExecutingAI=false;}}}if(!hasText&&!hasCalls){const rawKeys=result.raw?Object.keys(result.raw).join(', '):'null';const errDetail=result.raw&&result.raw.error?` (${result.raw.error.message||result.raw.error})`:'';setAiActionLog(prev=>[...prev,{type:'error',text:` AI không trả về lệnh hoặc text. Keys: [${rawKeys}]${errDetail}`,time:Date.now()}]);}if(prompt){promptHistRef.current=[...promptHistRef.current.slice(-49),prompt];setPromptHistory(promptHistRef.current);}setPromptHistIdx(-1);setAiActionLog(prev=>[...prev,{type:'status',text:` Hoàn tất.`,time:Date.now()}]);setAiPrompt('');setTimeout(()=>lucide.createIcons(),200);}catch(err){setAiActionLog(prev=>[...prev,{type:'error',text:` Lỗi: ${err.message}`,time:Date.now()}]);showToast(`AI Error: ${err.message}`,'error');}finally{setAiProcessing(false);}};// ── Split Track at Playhead ── +const handleSplitTrackAtTime=(trackId,clipId,time)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];const targetClipId=clipId||clips.find(c=>time>=c.startTime&&timec.id===targetClipId);if(!clip||!clip.buffer)return;const relTime=Math.max(0,time-clip.startTime);const sr=clip.buffer.sampleRate;const cutSample=Math.floor(relTime*sr);const originalData=clip.buffer.getChannelData(0);if(cutSample<=0||cutSample>=originalData.length){showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.","warning");return;}const beforeSnap=captureTrackSnapshot(trackId);const ctx=getAudioContext();const b1=ctx.createBuffer(1,cutSample,sr);b1.copyToChannel(originalData.subarray(0,cutSample),0);const b2=ctx.createBuffer(1,originalData.length-cutSample,sr);b2.copyToChannel(originalData.subarray(cutSample),0);const clip1={id:'clip_'+Date.now()+'_p1',name:`${clip.name.replace(' (Part 1)','').replace(' (Part 2)','')} (Part 1)`,buffer:b1,startTime:clip.startTime};const clip2={id:'clip_'+Date.now()+'_p2',name:`${clip.name.replace(' (Part 1)','').replace(' (Part 2)','')} (Part 2)`,buffer:b2,startTime:clip.startTime+cutSample/sr};setTracks(prev=>prev.map(t=>{if(t.id===trackId){const remainingClips=clips.filter(c=>c.id!==targetClipId);const updatedClips=[...remainingClips,clip1,clip2];return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}return t;}));setTimeout(()=>{const afterSnap=captureTrackSnapshot(trackId);pushAction('SPLIT_CLIP',trackId,beforeSnap,afterSnap);},50);showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`,"info");};const handleSplitTrack=trackId=>{handleSplitTrackAtTime(trackId,null,currentTime);};// ── Glue (Merge) Clips on Selected Track ── +const handleGlueTracks=()=>{const track=tracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Vui lòng chọn một track để thực hiện gộp (glue).','warning');return;}const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length<2){showToast('Cần ít nhất 2 clip trên track này để gộp (glue).','warning');return;}const beforeSnap=captureTrackSnapshot(track.id);const ctx=getAudioContext();const sr=clips[0].buffer.sampleRate;let minStart=Infinity;let maxEnd=-Infinity;clips.forEach(c=>{const start=c.startTime||0;const end=start+c.buffer.duration;minStart=Math.min(minStart,start);maxEnd=Math.max(maxEnd,end);});const newDur=maxEnd-minStart;const newBuffer=ctx.createBuffer(1,Math.ceil(newDur*sr),sr);const newData=newBuffer.getChannelData(0);clips.forEach(c=>{const data=c.buffer.getChannelData(0);const offset=Math.floor(((c.startTime||0)-minStart)*sr);for(let i=0;imaxPeak)maxPeak=abs;}if(maxPeak>1.0){for(let i=0;iprev.map(t=>{if(t.id===track.id){return{...t,clips:[mergedClip],buffer:newBuffer,startTime:minStart,name:mergedClip.name};}return t;}));setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('GLUE',track.id,beforeSnap,afterSnap);},50);showToast(`Đã gộp ${clips.length} clips thành công.`,'success');};// ── DAW Command Registration for AI (28_AI_PANEL.md §1 & §2) ── +useEffect(()=>{if(typeof window.DAWCommandDispatcher==='undefined')return;const api={createTrack:args=>{const name=args.name||`AI_Track_${Date.now()}`;const type=args.type||'audio';const newId=addNewTrack();if(name&&name!==`AI_Track_${Date.now()}`){updateTrackName(newId,name);}return{success:true,trackId:newId,name};},deleteTrack:args=>{const tid=args.track_id||selectedTrackId;if(!tid)return{success:false,error:'No track_id provided'};deleteTrack(tid);return{success:true,trackId:tid};},addClip:args=>{const trackId=args.track_id||selectedTrackId;const barDur=60/parseInt(bpm||120)*4;let startTime;if(args.start_time!==undefined&&args.start_time!==null)startTime=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)startTime=args.start_bar*barDur;else startTime=currentTime;const track=tracks.find(t=>t.id===trackId);if(!track)return{success:false,error:'Track not found'};const ctx=getAudioContext();const sr=44100;let duration;if(args.duration_seconds!==undefined&&args.duration_seconds!==null)duration=args.duration_seconds;else if(args.length_bars!==undefined&&args.length_bars!==null)duration=args.length_bars*barDur;else duration=2;const buffer=ctx.createBuffer(1,Math.floor(sr*duration),sr);const data=buffer.getChannelData(0);for(let i=0;iprev.map(t=>{if(t.id!==trackId)return t;const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];return{...t,clips:[...clips,{id:clipId,buffer,startTime,name:args.name||'AI Clip'}],buffer:clips.length>0?clips[0].buffer:buffer,startTime:clips.length>0?clips[0].startTime:startTime,name:clips.length>0?clips[0].name:args.name||t.name};}));return{success:true,clipId,trackId};},removeClip:args=>{const trackId=args.track_id||selectedTrackId;const clipId=args.clip_id;setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const updatedClips=(t.clips||[]).filter(c=>c.id!==clipId);return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));return{success:true};},setTrackVolume:args=>{const trackId=args.track_id||selectedTrackId;const vol=args.volume_db??args.volume??0;updateTrackVolumeDb(trackId,parseFloat(vol));return{success:true,trackId,volumeDb:vol};},setTrackPan:args=>{const trackId=args.track_id||selectedTrackId;const pan=args.pan??0;updateTrackPan(trackId,parseInt(pan));return{success:true,trackId,pan};},toggleMute:args=>{const trackId=args.track_id||selectedTrackId;toggleTrackMute(trackId);const track=tracks.find(t=>t.id===trackId);return{success:true,trackId,muted:track?track.muted:null};},toggleSolo:args=>{const trackId=args.track_id||selectedTrackId;toggleTrackSoloEvaluate(trackId);const track=tracks.find(t=>t.id===trackId);return{success:true,trackId,solo:track?track.solo:null};},processAudioDsp:args=>{const trackId=args.track_id||selectedTrackId;const action=args.action;const params=args.params||{};const track=tracks.find(t=>t.id===trackId);if(!track||!track.buffer)return{success:false,error:'Track has no audio buffer'};if(action==='normalize'){const channelData=track.buffer.getChannelData(0);let maxVal=0;for(let i=0;i0){const gain=1.0/maxVal;for(let i=0;i{const newLen=Math.round(data.length*r);const out=new Float32Array(newLen);for(let i=0;iprev.map(t=>t.id===trackId?{...t,buffer:newBuffer}:t));return{success:true,action:'pitch_shift',semitones};}return{success:false,error:`Unknown action: ${action}`};},renameTrack:args=>{const tid=args.track_id||selectedTrackId;const name=args.name;if(!tid)return{success:false,error:'No track_id provided'};if(!name)return{success:false,error:'No name provided'};updateTrackName(tid,name);return{success:true,trackId:tid,name};},setSelection:args=>{const barDur=60/parseInt(bpm||120)*4;let start,end;if(args.start_time!==undefined&&args.start_time!==null)start=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)start=args.start_bar*barDur;else start=currentTime;if(args.end_time!==undefined&&args.end_time!==null)end=args.end_time;else if(args.length_bars!==undefined&&args.length_bars!==null)end=start+args.length_bars*barDur;else if(args.end_bar!==undefined&&args.end_bar!==null)end=args.end_bar*barDur;else end=start+barDur;clearLocalSelection();setSelectionMode('global');setSelectionStart(start);setSelectionEnd(end);selectionRef.current={start,end};return{success:true,start:parseFloat(start.toFixed(3)),end:parseFloat(end.toFixed(3)),length:parseFloat((end-start).toFixed(3))};},cutAudio:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;const tid=args.track_id||currentSelTrackId;const track=currentTracks.find(t=>t.id===String(tid));if(!track)return{success:false,error:'Track not found'};if(!track.buffer)return{success:false,error:'Track has no audio buffer'};const barDur=60/parseInt(bpm||120)*4;const sel=selectionRef.current;let rawStart,rawEnd;if(args.start_time!==undefined&&args.start_time!==null)rawStart=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)rawStart=args.start_bar*barDur;else if(sel.start!==null)rawStart=sel.start;else rawStart=currentTime;if(args.end_time!==undefined&&args.end_time!==null)rawEnd=args.end_time;else if(args.end_bar!==undefined&&args.end_bar!==null)rawEnd=args.end_bar*barDur;else if(args.length_bars!==undefined&&args.length_bars!==null)rawEnd=rawStart+args.length_bars*barDur;else if(sel.end!==null&&sel.end>rawStart)rawEnd=sel.end;else return{success:false,error:'No end position provided. Provide end_time, end_bar, or length_bars.'};if(rawEnd<=rawStart)return{success:false,error:'End position must be after start position.'};const buffer=track.buffer;const ctx=getAudioContext();const snap=args.snap_silence!==false;const loopStart=snap?findZeroCrossing(buffer,rawStart):rawStart;const loopEnd=snap?findZeroCrossing(buffer,rawEnd):rawEnd;const sampleRate=buffer.sampleRate;const startSample=Math.max(0,Math.min(buffer.length-1,Math.floor(loopStart*sampleRate)));const endSample=Math.max(0,Math.min(buffer.length,Math.floor(loopEnd*sampleRate)));const sliceLength=endSample-startSample;if(sliceLength<=100)return{success:false,error:'Selection too short or invalid'};const numChannels=buffer.numberOfChannels||1;const slicedBuffer=ctx.createBuffer(numChannels,sliceLength,sampleRate);for(let c=0;ct.id===tid);const nextTracks=[...currentTracks];if(idx!==-1){nextTracks.splice(idx+1,0,newTrack);}else{nextTracks.push(newTrack);}if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;window.DAWCommandDispatcher.currentSelectedTrackId=newId;window.DAWCommandDispatcher.lastCutSourceTrackId=tid;window.DAWCommandDispatcher.lastCutNewTrackId=newId;}setTracks(nextTracks);setSelectedTrackId(newId);selectionRef.current={start:0,end:slicedBuffer.duration};clearLocalSelection();setSelectionMode('global');setSelectionStart(0);setSelectionEnd(slicedBuffer.duration);setTimeout(()=>lucide.createIcons(),200);return{success:true,trackId:newId,trackName:cutName,cutStart:parseFloat(loopStart.toFixed(3)),cutEnd:parseFloat(loopEnd.toFixed(3)),duration:parseFloat(slicedBuffer.duration.toFixed(3))};},scanTrack:args=>{const tid=args.track_id||selectedTrackId;const track=tracks.find(t=>t.id===tid);if(!track)return{success:false,error:'Track not found'};if(!track.buffer)return{success:false,error:'Track has no audio buffer. Load audio first.'};const buffer=track.buffer;const data=buffer.getChannelData(0);const sr=buffer.sampleRate;const channels=buffer.numberOfChannels;const duration=buffer.duration;const totalSamples=buffer.length;const windowSize=Math.min(sr*3,data.length);let detectedBPM=0;if(windowSize>sr){let maxCorr=0;for(let lag=Math.floor(sr*0.3);lag<=Math.floor(sr*2.0);lag++){let corr=0;const step=4;for(let i=0;imaxCorr){maxCorr=corr;detectedBPM=60/(lag/sr);}}}detectedBPM=Math.round(Math.min(300,Math.max(30,detectedBPM)));if(args.set_tempo!==false&&detectedBPM>0){setBpm(String(detectedBPM));}const bitDepth=16;const bitrate=Math.round(sr*channels*bitDepth/1000);return{success:true,trackId:tid,trackName:track.name,bpm:detectedBPM,sampleRate:sr,channels,duration:parseFloat(duration.toFixed(3)),totalSamples,bitDepth,bitrateKbps:bitrate,hasAudio:true};},setBpm:args=>{const bpmVal=args.bpm||args.tempo||120;setBpm(String(bpmVal));return{success:true,bpm:bpmVal};},setPlayhead:args=>{const barDur=60/parseInt(bpm||120)*4;let time;if(args.time!==undefined&&args.time!==null)time=args.time;else if(args.bar!==undefined&&args.bar!==null)time=args.bar*barDur;else time=0;handlePlayheadSet(time);return{success:true,time:parseFloat(time.toFixed(3))};},exportAudio:async args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let tid=args.track_id;if(tid&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(tid)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+tid===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){tid=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!tid)tid=currentSelTrackId;const track=tid&¤tTracks.find(t=>t.id===String(tid)||t.id==='track_'+tid);if(!track)return{success:false,error:'No track found'};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0)return{success:false,error:'Track has no audio clips'};const totalDuration=Math.max(...clips.map(c=>(c.startTime||0)+(c.buffer?c.buffer.duration/(c.speed||1.0):0)));const barDur=60/parseInt(bpm||120)*4;const sel=selectionRef.current;let rawStart,rawEnd;if(args.start_time!==undefined)rawStart=args.start_time;else if(args.start_bar!==undefined)rawStart=args.start_bar*barDur;else if(sel.start!==null)rawStart=sel.start;else rawStart=0;if(args.end_time!==undefined)rawEnd=args.end_time;else if(args.length_bars!==undefined)rawEnd=(rawStart||0)+args.length_bars*barDur;else if(args.end_bar!==undefined)rawEnd=args.end_bar*barDur;else if(sel.end!==null&&sel.end>rawStart)rawEnd=sel.end;else rawEnd=totalDuration;if(rawEnd<=rawStart)return{success:false,error:'Export range is empty or invalid.'};const ctx=getAudioContext();const sr=parseInt(args.sample_rate||'44100');const firstBuffer=clips.find(c=>c.buffer)?.buffer;const numCh=args.channels==='mono'?1:firstBuffer?firstBuffer.numberOfChannels:2;const bd=parseInt(args.bit_depth||'16');const fmt=args.format||'wav';const renderLength=rawEnd-rawStart;const offlineCtx=new OfflineAudioContext(numCh,Math.ceil(sr*renderLength),sr);clips.forEach(clip=>{if(!clip.buffer)return;const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(clipEnd<=rawStart||clipStart>=rawEnd)return;const source=offlineCtx.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(offlineCtx.destination);if(rawStart{for(let i=0;i>8&0xFF);vw.setUint8(ofs+2,v24>>16&0xFF);}ofs+=bps;}}const blob=new Blob([fileBuf],{type:'audio/wav'});const localUrl=URL.createObjectURL(blob);const targetFilename=`export_${Date.now()}.${fmt}`;const triggerDownload=(downloadUrl,finalFilename)=>{if(window.DAWCommandDispatcher?.isExecutingAI){showToast(`Xuất nhạc thành công (${fmt.toUpperCase()})!`,"success","Tải về",()=>{const a=document.createElement('a');a.href=downloadUrl;a.download=finalFilename;a.click();if(downloadUrl.startsWith('blob:')){URL.revokeObjectURL(downloadUrl);}});}else{const a=document.createElement('a');a.href=downloadUrl;a.download=finalFilename;a.click();showToast("Xuất bản âm thanh hoàn tất!","success");if(downloadUrl.startsWith('blob:')){URL.revokeObjectURL(downloadUrl);}}};if((fmt==='mp3'||fmt==='ogg')&&serverStatus==='connected'){try{const file=new File([blob],`export_ai.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload failed");const uploadData=await uploadResp.json();const uploadId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:uploadId,format:fmt,sample_rate:sr,bit_depth:bd})});if(!exportResp.ok)throw new Error("Export failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const downloadUrl=`${API_AUDIO}/download/${result.output_file_id}`;triggerDownload(downloadUrl,targetFilename);}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}. Tải về dạng WAV thay thế.`,"warning");triggerDownload(localUrl,`export_${Date.now()}.wav`);}}else{const finalFilename=fmt==='wav'?`export_${Date.now()}.wav`:`export_${Date.now()}.wav`;if(fmt!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}triggerDownload(localUrl,finalFilename);}return{success:true,trackId:tid,range:parseFloat((rawEnd-rawStart).toFixed(3))+'s',format:fmt,channels:numCh===1?'mono':'stereo'};},selectItem:args=>{if(args.select_all){setSelectedTrackId(null);clearLocalSelection();setSelectionMode('global');setSelectionStart(0);const maxDur=tracks.reduce((max,t)=>{const dur=t.buffer?t.buffer.duration:0;const clips=t.clips||[];const clipMax=clips.reduce((m,c)=>Math.max(m,(c.startTime||0)+(c.buffer?c.buffer.duration:0)),0);return Math.max(max,dur,clipMax);},0);setSelectionEnd(Math.max(maxDur,currentTime+10));return{success:true,selection:'all',duration:parseFloat(Math.max(maxDur,currentTime+10).toFixed(3))};}const tid=args.track_id||selectedTrackId;const track=tracks.find(t=>t.id===tid);if(!track)return{success:false,error:`Track ${tid} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,name:track.name,buffer:track.buffer,startTime:track.startTime||0}]:[];if(args.item_name){const match=clips.find(c=>c.name&&c.name.toLowerCase().includes(args.item_name.toLowerCase()));if(!match)return{success:false,error:`No clip matching "${args.item_name}" on track ${track.name}`};setSelectedTrackId(tid);clearLocalSelection();setSelectionMode('global');const start=match.startTime||0;const end=start+(match.buffer?match.buffer.duration:2);setSelectionStart(start);setSelectionEnd(end);return{success:true,trackId:tid,trackName:track.name,clipId:match.id,clipName:match.name,start:parseFloat(start.toFixed(3)),end:parseFloat(end.toFixed(3))};}setSelectedTrackId(tid);clearLocalSelection();setSelectionMode('global');const trackEnd=track.buffer?track.buffer.duration:clips.length>0?Math.max(...clips.map(c=>(c.startTime||0)+(c.buffer?c.buffer.duration:0))):4;setSelectionStart(0);setSelectionEnd(trackEnd);return{success:true,trackId:tid,trackName:track.name,duration:parseFloat(trackEnd.toFixed(3))};},addMarker:args=>{const trackId=args.track_id||selectedTrackId;const time=args.time??currentTime;const track=tracks.find(t=>t.id===trackId);if(!track)return{success:false,error:'Track not found'};setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,markers:[...(t.markers||[]),{id:'ai_marker_'+Date.now(),time,label:args.label||'AI Marker'}]};}));return{success:true,trackId,time};},fadeIn:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let trackIdRaw=args.track_id;if(trackIdRaw&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(trackIdRaw)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+trackIdRaw===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){trackIdRaw=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!trackIdRaw)trackIdRaw=currentSelTrackId;const track=currentTracks.find(t=>t.id===String(trackIdRaw)||t.id==='track_'+trackIdRaw);if(!track)return{success:false,error:`Track ${trackIdRaw} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0)return{success:false,error:'No clips on track'};let clip=null;if(args.clip_id){clip=clips.find(c=>c.id===args.clip_id);}else if(args.clip_index!==undefined){const idx=parseInt(args.clip_index);const realIdx=idx>0?idx-1:0;clip=clips[realIdx]||clips[0];}else{clip=clips[0];}if(!clip||!clip.buffer)return{success:false,error:'Clip has no audio buffer'};const duration=parseFloat(args.duration_seconds||3);const buffer=clip.buffer;const sr=buffer.sampleRate;const numChannels=buffer.numberOfChannels;const length=buffer.length;const ctx=getAudioContext();const newBuffer=ctx.createBuffer(numChannels,length,sr);for(let c=0;c{if(t.id!==track.id)return t;const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=existingClips.map(c=>{if(c.id===clip.id||clip.id.startsWith('default_')&&c.id==='default'){return{...c,buffer:newBuffer};}return c;});const mainBuffer=updatedClips[0]?.buffer||t.buffer;return{...t,clips:updatedClips,buffer:mainBuffer};});if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;}setTracks(nextTracks);setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('AI_FADE_IN',track.id,beforeSnap,afterSnap);},50);return{success:true,trackId:track.id,clipId:clip.id,duration_seconds:duration};},fadeOut:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let trackIdRaw=args.track_id;if(trackIdRaw&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(trackIdRaw)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+trackIdRaw===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){trackIdRaw=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!trackIdRaw)trackIdRaw=currentSelTrackId;const track=currentTracks.find(t=>t.id===String(trackIdRaw)||t.id==='track_'+trackIdRaw);if(!track)return{success:false,error:`Track ${trackIdRaw} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0)return{success:false,error:'No clips on track'};let clip=null;if(args.clip_id){clip=clips.find(c=>c.id===args.clip_id);}else if(args.clip_index!==undefined){const idx=parseInt(args.clip_index);const realIdx=idx>0?idx-1:0;clip=clips[realIdx]||clips[0];}else{clip=clips[0];}if(!clip||!clip.buffer)return{success:false,error:'Clip has no audio buffer'};const duration=parseFloat(args.duration_seconds||3);const buffer=clip.buffer;const sr=buffer.sampleRate;const numChannels=buffer.numberOfChannels;const length=buffer.length;const ctx=getAudioContext();const newBuffer=ctx.createBuffer(numChannels,length,sr);for(let c=0;c{if(t.id!==track.id)return t;const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=existingClips.map(c=>{if(c.id===clip.id||clip.id.startsWith('default_')&&c.id==='default'){return{...c,buffer:newBuffer};}return c;});const mainBuffer=updatedClips[0]?.buffer||t.buffer;return{...t,clips:updatedClips,buffer:mainBuffer};});if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;}setTracks(nextTracks);setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('AI_FADE_OUT',track.id,beforeSnap,afterSnap);},50);return{success:true,trackId:track.id,clipId:clip.id,duration_seconds:duration};}};window.DAWCommandDispatcher.registerDAWCommands(api);},[tracks,selectedTrackId,currentTime,bpm]);// ── Save AI config to localStorage ── +useEffect(()=>{localStorage.setItem('ai_base_url',aiConfig.baseUrl);localStorage.setItem('ai_api_key',aiConfig.apiKey);localStorage.setItem('ai_model',aiConfig.model);},[aiConfig]);// ── Auto-save user preferences (panel state, provider) ── +const prefsRef=useRef({});prefsRef.current={showAIPanel,showExportPanel,showSelectionPanel,showPythonToolsPanel,showMediaExplorer,showFxRack,showMidiEvents,panelPositions,rightSidebarWidth,mediaExplorerHeight,selectedProviderId};useEffect(()=>{const prefs=prefsRef.current;localStorage.setItem('sonic_preferences',JSON.stringify(prefs));if(!currentUser||currentUser==='cached')return;const timer=setTimeout(async()=>{try{await window.SonicAPI.savePreferences(prefs);}catch(e){}},2000);return()=>clearTimeout(timer);},[showAIPanel,showExportPanel,showSelectionPanel,showPythonToolsPanel,showMediaExplorer,showFxRack,showMidiEvents,panelPositions,rightSidebarWidth,mediaExplorerHeight,selectedProviderId,currentUser]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"h-full w-full flex flex-col bg-[#1e1e1e]"},/*#__PURE__*/React.createElement("header",{className:"h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none"},[{label:'File',items:[{label:'New Project',icon:'file-plus',shortcut:'Ctrl+N',action:()=>{setTracks([{id:'1',name:'Track 01',buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:'#0f766e',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]},{id:'2',name:'Track 02',buffer:null,startTime:0,height:128,volumeDb:0,pan:0,muted:false,solo:false,color:'#1d4ed8',markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]}]);setSelectedTrackId('1');setProjectName('');setCurrentProjectId(null);localStorage.removeItem('sonic_project_name');localStorage.removeItem('sonic_project_id');showToast('New project created','info');}},{label:'Open Project...',icon:'folder-open',shortcut:'Ctrl+O',action:()=>handleImportSFS()},{label:'Save Project',icon:'upload-cloud',shortcut:'Ctrl+S',action:()=>handleSaveProject()},{label:'Save As...',icon:'download',shortcut:'Ctrl+Alt+S',action:()=>setSaveAsModalOpen(true)},{sep:true},{label:'Config AI Providers...',icon:'settings',action:()=>setAiConfigModalOpen(true)},{label:'Import Audio...',icon:'file-input',shortcut:'Ctrl+Alt+I',action:()=>{const input=document.createElement('input');input.type='file';input.accept='audio/*';input.onchange=async e=>{if(e.target.files[0]){addNewTrack();const newId=(tracks.length+1).toString();setTimeout(()=>loadFileOnTrack(newId,e.target.files[0]),100);}};input.click();showToast('Import audio','info');}},{label:'Export Mix...',icon:'file-output',action:()=>triggerWavExport()},{sep:true},...(currentUser?[{label:'Profile',icon:'user',action:()=>setProfileModalOpen(true)}]:[]),...(currentUser&¤tUser.role==='admin'?[{label:'System Manager',icon:'settings',action:()=>setSystemManagerModalOpen(true)}]:[]),{label:'Logout',icon:'log-out',action:()=>handleLogout()}]},{label:'Edit',items:[{label:'Insert New Track',icon:'plus',shortcut:'Ctrl+I',action:addNewTrack},{label:'Insert Music to Track',icon:'music',shortcut:'Ctrl+Alt+I',action:()=>showToast('Select music file to insert','info')},{sep:true},{label:'Edit in New Tab',icon:'file-edit',shortcut:'Ctrl+E',action:()=>openTempTab()},{label:'Split at Playhead',icon:'scissors',shortcut:'S',action:()=>handleSplitTrack(selectedTrackId)},{label:'Merge Tracks',icon:'combine',shortcut:'Ctrl+M',action:()=>{handleMergeTracks();}},{sep:true},{label:'Undo',icon:'undo',shortcut:'Ctrl+Z',action:()=>{handleUndo();}},{label:'Redo',icon:'redo',shortcut:'Ctrl+Y',action:()=>{handleRedo();}},{sep:true},{label:'Copy',icon:'copy',shortcut:'Ctrl+C',action:()=>{handleCopyTrack();}},{label:'Cut',icon:'scissors',shortcut:'Ctrl+X',action:()=>{handleCutTrack();}},{label:'Paste',icon:'clipboard',shortcut:'Ctrl+V',action:handlePasteTrack},{sep:true},{label:'Delete Track',icon:'trash-2',shortcut:'Del',action:()=>{handleDeleteTrack();}}]},{label:'Insert',items:[...(!sessionTabs.some(s=>s.id===activeTab)?[{label:'Insert Section',icon:'folder-plus',action:insertSectionAtPlayhead}]:[]),{label:'Insert MIDI item',icon:'music',action:insertMidiItemAtPlayhead},{label:'Insert sound clip',icon:'file-input',action:insertSoundClipAtCursor},{label:'Insert track',icon:'plus',action:insertTrackBelow}]},{label:'View',items:[{label:'Master Track',icon:'disc',action:()=>showToast('Master track view','info')},{label:'Maker View',icon:'layout',action:()=>showToast('Maker view','info')},{label:'Mixer',icon:'sliders',action:()=>showToast('Mixer panel','info')},{label:'Tempo Track',icon:'timer',action:()=>showToast('Tempo track','info')},{label:'Video',icon:'film',action:()=>showToast('Video panel','info')},{label:'Media Explorer',icon:'folder-search',action:()=>showToast('Media explorer','info')}]},{label:'Tools',items:[{label:'Config AI Providers...',icon:'settings',action:()=>setAiConfigModalOpen(true)},{label:'DSP Tools Panel',icon:'wrench',action:()=>openPanel('python_tools')}]},{label:'Help',items:[{label:'About SonicForge',icon:'info',action:()=>showToast('SonicForge Studio v1.0 - Professional DAW','info')}]}].map(menu=>/*#__PURE__*/React.createElement("div",{key:menu.label,className:"relative"},/*#__PURE__*/React.createElement("button",{onClick:()=>setMenuOpen(menuOpen===menu.label?null:menu.label),className:`px-3 py-1 text-xs font-medium transition rounded ${menuOpen===menu.label?'bg-zinc-700 text-zinc-100':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`},menu.label),menuOpen===menu.label&&/*#__PURE__*/React.createElement("div",{className:`absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label==='Edit'?'w-72':'w-64'}`,onClick:()=>setMenuOpen(null)},menu.items.map((item,i)=>item.sep?/*#__PURE__*/React.createElement("div",{key:i,className:"h-px bg-zinc-700 my-1"}):/*#__PURE__*/React.createElement("button",{key:item.label,onClick:e=>{e.stopPropagation();item.action();setMenuOpen(null);},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":item.icon,className:"w-3.5 h-3.5 text-zinc-500 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},item.label),item.shortcut&&/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},item.shortcut)))))),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 px-2"},/*#__PURE__*/React.createElement("span",{className:`text-xs font-bold uppercase px-1.5 py-0.5 rounded ${serverStatus==='connected'?'bg-emerald-950 text-emerald-400':serverStatus==='checking'?'bg-amber-950 text-amber-400':'bg-red-950 text-red-400'}`},"Server: ",serverStatus),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowAIConfig(!showAIConfig),className:`px-1.5 py-0.5 rounded text-xs border transition ${showAIConfig?'bg-purple-900 text-purple-200 border-purple-700':'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3"}))))),menuOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-40",onClick:()=>setMenuOpen(null)}),/*#__PURE__*/React.createElement("div",{className:"h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto"},/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('main'),className:`px-3 text-xs font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${activeTab==='main'?'text-cyan-400 border-cyan-500 bg-zinc-800/50':'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"layout-dashboard",className:"w-3 h-3"}))," Main Session"),sessionTabs.map(st=>/*#__PURE__*/React.createElement("div",{key:st.id,className:"flex items-stretch"},/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab(st.id),className:`px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab===st.id?'text-cyan-400 border-cyan-500 bg-zinc-800/50':'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"layout-dashboard",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"max-w-[120px] truncate"},st.name)),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSessionTab(st.id),className:"px-1 text-zinc-600 hover:text-red-400 transition text-xs",title:"Close tab"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))))),subTabs.map(st=>/*#__PURE__*/React.createElement("div",{key:st.id,className:"flex items-stretch"},/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab(st.id),className:`px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab===st.id?'text-amber-400 border-amber-500 bg-zinc-800/50':'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-edit",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"max-w-[100px] truncate"},st.label)),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSubTab(st.id),className:"px-1 text-zinc-600 hover:text-red-400 transition text-xs",title:"Close tab"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))))),showAIConfig&&/*#__PURE__*/React.createElement("div",{className:"bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all"},/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-4 h-4"}))," Cấu hình cổng kết nối API"),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-1 md:grid-cols-3 gap-2 text-xs"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"Endpoint Base URL"),/*#__PURE__*/React.createElement("input",{type:"text",value:aiConfig.baseUrl,onChange:e=>setAiConfig(prev=>({...prev,baseUrl:e.target.value})),className:"bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs",placeholder:"https://api.openai.com/v1"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"API Token Key"),/*#__PURE__*/React.createElement("input",{type:"password",value:aiConfig.apiKey,onChange:e=>setAiConfig(prev=>({...prev,apiKey:e.target.value})),className:"bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs",placeholder:"sk-..."})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase"},"Model Name"),/*#__PURE__*/React.createElement("input",{type:"text",value:aiConfig.model,onChange:e=>setAiConfig(prev=>({...prev,model:e.target.value})),className:"bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs",placeholder:"gpt-4o-mini"})))),/*#__PURE__*/React.createElement("div",{className:"h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 shadow-lg",title:"Kéo để di chuyển toolbar",style:{cursor:'grab'}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-0.5 mr-1 text-zinc-600"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('select');showToast('Select Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='select'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Select Tool (V)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"mouse-pointer",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('grab');showToast('Grab Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='grab'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Grab Tool (H)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"hand",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('razor');showToast('Razor Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='razor'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Razor Tool (C)"},/*#__PURE__*/React.createElement("svg",{className:"w-3.5 h-3.5 text-orange-400",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round"},/*#__PURE__*/React.createElement("path",{d:"M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"}),/*#__PURE__*/React.createElement("path",{d:"M4 9h16l-3 9H7z"}),/*#__PURE__*/React.createElement("circle",{cx:"12",cy:"6",r:"1"}))),/*#__PURE__*/React.createElement("button",{onClick:handleGlueTracks,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800",title:"Glue Clips"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"link",className:"w-3.5 h-3.5"})))),/*#__PURE__*/React.createElement("button",{onClick:()=>{setActiveTool('pen');showToast('Pen Tool','info');},className:`w-7 h-7 flex items-center justify-center rounded ${activeTool==='pen'?'bg-cyan-700 text-white':'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`,title:"Pen Tool (P)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"pen-tool",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-700 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:handleCutTrack,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800",title:"Cut (Ctrl+X)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handleCopyTrack,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800",title:"Copy (Ctrl+C)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handlePasteTrack,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800",title:"Paste (Ctrl+V)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-700 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition",title:"Thêm Track Mới (Ctrl+I)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3.5 h-3.5"})),/*#__PURE__*/React.createElement("span",null,"Track")),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-700 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:handleUndo,disabled:undoStack.length===0,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30",title:"Undo (Ctrl+Z)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"undo",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handleRedo,disabled:redoStack.length===0,className:"w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30",title:"Redo (Ctrl+Y)"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"redo",className:"w-3.5 h-3.5"})))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-800 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{if(activeTab!=='main'){setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:0}:s));}else{setCurrentTime(0);}},className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Quay lại đầu"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-back",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{if(activeTab!=='main'){setSubTabs(prev=>prev.map(s=>{if(s.id!==activeTab)return s;const left=s.selectionStart!==null&&s.selectionEnd!==null?Math.min(s.selectionStart,s.selectionEnd):null;return left!==null?{...s,currentTime:left}:s;}));}else{if(selLeft!==null)setCurrentTime(selLeft);}},className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Đầu vùng chọn"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"step-back",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:handlePlayPause,className:`w-7 h-7 flex items-center justify-center rounded border transition ${isPlaying?'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500':'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`,title:isPlaying?"Tạm dừng":"Play"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":isPlaying?"pause":"play",className:"w-3.5 h-3.5 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:handleStop,className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Stop"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-3.5 h-3.5 fill-current"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{if(activeTab!=='main'){setSubTabs(prev=>prev.map(s=>{if(s.id!==activeTab)return s;const right=s.selectionStart!==null&&s.selectionEnd!==null?Math.max(s.selectionStart,s.selectionEnd):null;return right!==null?{...s,currentTime:right}:s;}));}else{if(selRight!==null)setCurrentTime(selRight);}},className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Cuối vùng chọn"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"step-forward",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>{if(activeTab!=='main'){setSubTabs(prev=>prev.map(s=>{if(s.id!==activeTab)return s;const duration=s.buffer?s.buffer.duration/(s.speed||1.0):0;return{...s,currentTime:duration};}));}else{setCurrentTime(maxDuration);}},className:"w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition",title:"Đến cuối"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"skip-forward",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-5 bg-zinc-800 mx-0.5"}),/*#__PURE__*/React.createElement("button",{onClick:()=>setIsLoopingSelection(prev=>!prev),className:`w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection?'bg-amber-600 text-black border-amber-500 hover:bg-amber-500':'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`,title:isLoopingSelection?selLeft!==null&&selRight!==null?"Loop vùng chọn":"Loop timeline":"Bật loop"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500 font-bold uppercase ml-2"},"Snap"),/*#__PURE__*/React.createElement("select",{value:snapValue,onChange:e=>setSnapValue(e.target.value),className:"bg-black text-white text-[14px] px-1.5 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer"},/*#__PURE__*/React.createElement("option",{value:"free"},"Free"),/*#__PURE__*/React.createElement("option",{value:"1"},"1"),/*#__PURE__*/React.createElement("option",{value:"1/2"},"1/2"),/*#__PURE__*/React.createElement("option",{value:"1/4"},"1/4"),/*#__PURE__*/React.createElement("option",{value:"1/8"},"1/8"),/*#__PURE__*/React.createElement("option",{value:"1/16"},"1/16"),/*#__PURE__*/React.createElement("option",{value:"1/32"},"1/32")),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-6 bg-zinc-800 mx-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500 font-bold"},"Bars:"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",value:beginBar,onChange:e=>{const b=parseInt(e.target.value)||1;setBeginBar(b);const beatDuration=60/parseInt(bpm||120);const t=(b-1)*beatDuration*4;clearLocalSelection();setSelectionMode('global');setSelectionStart(t);setSelectionEnd(t+beatDuration*4);},className:"w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500"},"-"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",value:endBar,onChange:e=>{const b=parseInt(e.target.value)||1;setEndBar(b);const beatDuration=60/parseInt(bpm||120);const t=(b-1)*beatDuration*4;setSelectionEnd(t+beatDuration*4);setNumberBar(b-beginBar+1);},className:"w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500"},"#"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",value:numberBar,readOnly:true,className:"w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono"}),/*#__PURE__*/React.createElement("div",{className:"w-[1px] h-6 bg-zinc-800 mx-1.5"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500"},"Start:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selLeft!==null?formatTime(selLeft):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));clearLocalSelection();setSelectionMode('global');setSelectionStart(secs);}},className:"w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500"},"End:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selRight!==null?formatTime(selRight):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));setSelectionEnd(secs);}},className:"w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] text-zinc-500"},"Len:"),/*#__PURE__*/React.createElement("input",{type:"text",value:selLeft!==null&&selRight!==null?formatTime(Math.abs(selRight-selLeft)):'',onChange:e=>{const parts=e.target.value.split(/[:.]/);if(parts.length===3&&selLeft!==null){const secs=parseInt(parts[0])*60+parseInt(parts[1])+parseFloat('0.'+(parts[2]||'0'));setSelectionEnd(selLeft+secs);}},className:"w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono"}),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold font-mono text-zinc-100"},formatTime(currentTime))),(()=>{const dockPanels={top:[],right:[],bottom:[],left:[]};const addPanel=(id,pos,visible)=>{if(visible)dockPanels[pos].push(id);};addPanel('export',panelPositions.export,showExportPanel);addPanel('ai',panelPositions.ai,showAIPanel);addPanel('python_tools',panelPositions.python_tools||'bottom',showPythonToolsPanel);addPanel('selection',panelPositions.selection,showSelectionPanel);addPanel('media_explorer','bottom',showMediaExplorer);addPanel('fx_rack',panelPositions.fx_rack||'bottom',showFxRack);addPanel('midi_events',panelPositions.midi_events||'bottom',showMidiEvents);const closePanel=id=>{if(id==='export')setShowExportPanel(false);else if(id==='ai')setShowAIPanel(false);else if(id==='python_tools')setShowPythonToolsPanel(false);else if(id==='selection')setShowSelectionPanel(false);else if(id==='media_explorer')setShowMediaExplorer(false);else if(id==='fx_rack')setShowFxRack(false);else if(id==='midi_events')setShowMidiEvents(false);};const renderPanelContent=panelId=>{const h=id=>e=>{startPanelDrag(id,e);};if(panelId==='export')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('export',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-zinc-200 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-3.5 h-3.5 text-cyan-400"}))," Export"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('export'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Ngu\u1ed3n"),/*#__PURE__*/React.createElement("select",{value:exportSettings.source,onChange:e=>setExportSettings(p=>({...p,source:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"project"},"Project (Mix)"),/*#__PURE__*/React.createElement("option",{value:"track_mix"},"Track Selection"),/*#__PURE__*/React.createElement("option",{value:"active_clip"},"Active Clip"),/*#__PURE__*/React.createElement("option",{value:"clip_selection"},"Clip Selection"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"\u0110\u1ecbnh d\u1ea1ng"),/*#__PURE__*/React.createElement("select",{value:exportSettings.format,onChange:e=>setExportSettings(p=>({...p,format:e.target.value,sampleRate:e.target.value==='wav'?'44100':e.target.value==='mp3'?'44100':'44100',bitDepth:e.target.value==='wav'?'16':'16',quality:'44khz'})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"wav"},"WAV"),/*#__PURE__*/React.createElement("option",{value:"mp3"},"MP3"),/*#__PURE__*/React.createElement("option",{value:"ogg"},"OGG")))),exportSettings.format==='wav'?/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"SR (Hz)"),/*#__PURE__*/React.createElement("select",{value:exportSettings.sampleRate,onChange:e=>setExportSettings(p=>({...p,sampleRate:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"22500"},"22500"),/*#__PURE__*/React.createElement("option",{value:"44100"},"44100"))),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Bit"),/*#__PURE__*/React.createElement("select",{value:exportSettings.bitDepth,onChange:e=>setExportSettings(p=>({...p,bitDepth:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"8"},"8"),/*#__PURE__*/React.createElement("option",{value:"16"},"16"),/*#__PURE__*/React.createElement("option",{value:"24"},"24")))):/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Ch\u1ea5t l\u01b0\u1ee3ng"),/*#__PURE__*/React.createElement("select",{value:exportSettings.quality,onChange:e=>setExportSettings(p=>({...p,quality:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"44khz"},"44kHz"),/*#__PURE__*/React.createElement("option",{value:"lossless"},"Lossless"))),/*#__PURE__*/React.createElement("div",null)),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Kênh"),/*#__PURE__*/React.createElement("select",{value:exportSettings.channels,onChange:e=>setExportSettings(p=>({...p,channels:e.target.value})),className:"w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"},/*#__PURE__*/React.createElement("option",{value:"mono"},"Mono"),/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"))),/*#__PURE__*/React.createElement("div",null)),/*#__PURE__*/React.createElement("button",{onClick:triggerWavExport,disabled:isExporting,className:"w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download-cloud",className:"w-3 h-3"})),isExporting?'...':'Export'));if(panelId==='ai')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1.5 flex-1 min-h-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('ai',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-zinc-200 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3.5 h-3.5 text-purple-400"}))," AI Copilot"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:()=>{setAiActionLog([]);showToast('Đã xoá nhật ký AI.','info');},className:"text-zinc-600 hover:text-zinc-300",title:"Clear log"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3 h-3"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('ai'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 w-full min-w-0 pb-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3 text-purple-400"})),/*#__PURE__*/React.createElement("select",{value:selectedProviderId,onChange:e=>setSelectedProviderId(e.target.value),className:"flex-1 min-w-0 max-w-full bg-[#141414] text-zinc-200 border border-zinc-700 rounded px-1 py-0.5 text-xs font-mono focus:outline-none focus:border-purple-600 truncate"},aiProviders.length===0?/*#__PURE__*/React.createElement("option",{value:""},"Chưa có provider"):aiProviders.map(p=>/*#__PURE__*/React.createElement("option",{key:p.id,value:p.id},p.name,p.is_active?'':' (inactive)')))),/*#__PURE__*/React.createElement("div",{className:"border-t border-zinc-800 pt-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement("button",{onClick:()=>{if(window.DAWCommandDispatcher&&window.DAWCommandDispatcher.undo){const entry=window.DAWCommandDispatcher.undo();if(entry){setAiActionLog(prev=>[...prev,{type:'undo',text:`Undo: ${entry.name}`,time:Date.now()}]);showToast(`Undo AI: ${entry.name}`,'info');}}else{handleUndo();setAiActionLog(prev=>[...prev,{type:'undo',text:'Undo (Ctrl+Z)',time:Date.now()}]);}},className:"w-full text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded py-0.5"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"rotate-ccw",className:"w-3 h-3"}),"Undo"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 min-h-0 flex flex-col overflow-hidden mt-1"},/*#__PURE__*/React.createElement("div",{className:"text-[10px] font-bold text-zinc-400 uppercase shrink-0 pb-0.5"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center gap-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"list",className:"w-3 h-3"})," Action Log")),/*#__PURE__*/React.createElement("div",{ref:actionLogContainerRef,className:"flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text"},aiActionLog.length===0?/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-600 italic select-text"},"Chưa có hành động nào."):aiActionLog.map((entry,i)=>/*#__PURE__*/React.createElement("div",{key:i,className:`text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type==='error'?'text-red-400':entry.type==='status'?'text-zinc-400 italic':entry.type==='undo'?'text-amber-400':'text-zinc-300'}`},new Date(entry.time).toLocaleTimeString(),entry.text)))),/*#__PURE__*/React.createElement("div",{className:"border-t border-zinc-800 pt-1.5 mt-1 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"message-square",className:"w-3 h-3"}))," Copilot Prompt"),/*#__PURE__*/React.createElement("textarea",{value:aiPrompt,onChange:e=>setAiPrompt(e.target.value),placeholder:"Nhập lệnh điều khiển DAW... (VD: Tạo track mới tên Beat, đặt BPM 128)",className:"w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-none",rows:2,onKeyDown:e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();handleAISend();}else if(e.key==='ArrowUp'&&promptHistRef.current.length>0){e.preventDefault();const idx=promptHistIdx===-1?promptHistRef.current.length-1:Math.max(0,promptHistIdx-1);setPromptHistIdx(idx);setAiPrompt(promptHistRef.current[idx]);}else if(e.key==='ArrowDown'){e.preventDefault();if(promptHistIdx===-1)return;const idx=promptHistIdx+1;if(idx>=promptHistRef.current.length){setPromptHistIdx(-1);setAiPrompt('');}else{setPromptHistIdx(idx);setAiPrompt(promptHistRef.current[idx]);}}}})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement("button",{onClick:handleAISend,disabled:aiProcessing,className:"flex-1 py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-xs border border-purple-500 flex items-center justify-center gap-1"},aiProcessing?'Đang suy luận...':/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"send",className:"w-3 h-3"}))," Gửi")),/*#__PURE__*/React.createElement("button",{onClick:()=>{setAiPrompt('');setAiActionLog([]);},className:"px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700"},"Clear")),/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-600 shrink-0"},"Enter để gửi nhanh"));if(panelId==='python_tools')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('python_tools',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-amber-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"wrench",className:"w-3.5 h-3.5 text-amber-400"}))," DSP Tools"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('python_tools'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"p-1.5 bg-[#141414] rounded border border-zinc-800 text-[10px] font-mono text-zinc-400 space-y-0.5 leading-relaxed"},dspSelectionStats?[/*#__PURE__*/React.createElement("div",{key:"track"},`Track: ${dspSelectionStats.trackName}`),/*#__PURE__*/React.createElement("div",{key:"range"},`Range: ${dspSelectionStats.timeRange}`),/*#__PURE__*/React.createElement("div",{key:"ch"},`Channels: ${dspSelectionStats.channels}`),/*#__PURE__*/React.createElement("div",{key:"peak"},`Peak Vol: ${dspSelectionStats.peakVolume}`)]:"Chưa chọn track"),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1 text-xs"},/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('normalize'),className:"py-1 bg-amber-950/80 hover:bg-amber-900 text-amber-300 border border-amber-800/80 rounded font-bold flex items-center justify-center gap-1"},"⚡ Peak Norm (0dB)"),/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('invert_phase'),className:"py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1"},"🔄 Phase Invert"),/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('swap_channels'),className:"py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1"},"🔀 Swap L/R"),/*#__PURE__*/React.createElement("button",{onClick:()=>runPythonTool('synth_wave'),className:"py-1 bg-teal-950/80 hover:bg-teal-900 text-teal-300 border border-teal-800/80 rounded font-bold flex items-center justify-center gap-1"},"🎹 Gen Synth Tone")));if(panelId==='selection')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('selection',e)},/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500 font-bold uppercase flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"}))," Selection"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('selection'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Start"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.01",value:selectionStats.start,onChange:e=>handleSelectionInputChange('start',e.target.value),className:"w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"End"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.01",value:selectionStats.end,onChange:e=>handleSelectionInputChange('end',e.target.value),className:"w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col justify-center"},/*#__PURE__*/React.createElement("span",{className:"text-[7px] text-zinc-500 font-bold uppercase"},"Len"),/*#__PURE__*/React.createElement("span",{className:"text-zinc-200 font-mono text-xs font-semibold mt-0.5"},selectionStats.length,"s"))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"Begin Bar"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",value:beginBar,onChange:e=>{const b=parseInt(e.target.value)||1;setBeginBar(b);const beatDuration=60/parseInt(bpm||120);const t=(b-1)*beatDuration*4;clearLocalSelection();setSelectionMode('global');setSelectionStart(t);setSelectionEnd(t+beatDuration*4);},className:"w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("span",{className:"block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"},"End Bar"),/*#__PURE__*/React.createElement("input",{type:"number",min:"1",value:endBar,onChange:e=>{const b=parseInt(e.target.value)||1;setEndBar(b);const beatDuration=60/parseInt(bpm||120);const t=(b-1)*beatDuration*4;setSelectionEnd(t+beatDuration*4);setNumberBar(b-beginBar+1);},className:"w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none"})),/*#__PURE__*/React.createElement("div",{className:"flex flex-col justify-center"},/*#__PURE__*/React.createElement("span",{className:"text-[7px] text-zinc-500 font-bold uppercase"},"# Bars"),/*#__PURE__*/React.createElement("span",{className:"text-zinc-200 font-mono text-xs font-semibold mt-0.5"},numberBar))));if(panelId==='media_explorer')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('media_explorer',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-emerald-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"folder-open",className:"w-3.5 h-3.5 text-emerald-400"}))," Media Explorer"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('media_explorer'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto text-xs text-zinc-400 font-mono p-2"},"// Placeholder: Media files browser"));if(panelId==='fx_rack')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('fx_rack',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-rose-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3.5 h-3.5 text-rose-400"}))," Plugin FX Rack"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('fx_rack'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 text-xs text-zinc-500 italic flex items-center justify-center"},"No FX plugins loaded"));if(panelId==='midi_events')return/*#__PURE__*/React.createElement("div",{className:"flex flex-col h-full gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",onMouseDown:e=>startPanelDrag('midi_events',e)},/*#__PURE__*/React.createElement("h3",{className:"font-bold text-xs text-sky-300 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"grip-vertical",className:"w-3 h-3 text-zinc-500"})),/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3.5 h-3.5 text-sky-400"}))," MIDI Event List"),/*#__PURE__*/React.createElement("button",{onClick:()=>closePanel('midi_events'),className:"text-zinc-600 hover:text-zinc-300"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 text-xs text-zinc-500 italic flex items-center justify-center"},"No MIDI events selected"));return null;};const renderDock=(pos,title)=>{const panels=dockPanels[pos];if(panels.length===0)return null;const isSide=pos==='left'||pos==='right';const borderClass=pos==='left'?'border-r':pos==='right'?'border-l':pos==='top'?'border-b':'border-t';const bgClass='bg-[#1e1e1e]';const highlight=panelDragRef.current&&panelDropZone===pos;if(pos==='right')return/*#__PURE__*/React.createElement("div",{id:"right-sidebar",className:`${borderClass} ${bgClass} flex flex-col overflow-hidden select-none h-full`,style:{width:`${rightSidebarWidth}px`,minWidth:'200px',maxWidth:'600px',flexShrink:0}},/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col gap-2 p-2 overflow-hidden h-full"},panels.map((p,idx)=>/*#__PURE__*/React.createElement(React.Fragment,{key:p},/*#__PURE__*/React.createElement("div",{className:'flex flex-col flex-1 min-h-0 border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-3'},renderPanelContent(p)),idx/*#__PURE__*/React.createElement("div",{key:p,className:`${isSide?'w-full':'flex-none w-[320px]'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2`},renderPanelContent(p))));};return/*#__PURE__*/React.createElement("div",{ref:workspaceRef,className:"flex-1 flex flex-col overflow-hidden select-none daw-bg relative"},panelDragRef.current&&panelDropZone&&/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 z-50 pointer-events-none"},panelDropZone==='top'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='bottom'&&/*#__PURE__*/React.createElement("div",{className:"absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='left'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"}),panelDropZone==='right'&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"})),dragGhostPanel&&dragGhostPos&&/*#__PURE__*/React.createElement("div",{className:"fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56",style:{left:dragGhostPos.x,top:dragGhostPos.y}},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-xs text-zinc-200 font-bold"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"move",className:"w-3.5 h-3.5 text-cyan-400"})),dragGhostPanel==='export'?'Export Panel':dragGhostPanel==='ai'?'AI Panel':dragGhostPanel==='python_tools'?'Audio Processing Panel':'Selection Panel'),/*#__PURE__*/React.createElement("div",{className:"text-xs text-zinc-500 mt-1"},"Drop at edge to dock")),renderDock('top','Top'),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden"},renderDock('left','Left'),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col overflow-hidden min-w-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden"},activeTab==='main'||sessionTabs.some(s=>s.id===activeTab)?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{ref:tcpContainerRef,onScroll:handleTCPScroll,className:"w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",style:{scrollbarWidth:'none',msOverflowStyle:'none'}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-300 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3.5 h-3.5 text-cyan-400"})),"TRACKS (",activeTracks.length,")"),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3 h-3"}))," Add Track")),/*#__PURE__*/React.createElement("div",{className:"sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between w-full"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-purple-400 font-mono"},"TM"),/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300"},"Tempo")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("input",{type:"number",value:bpm,onChange:e=>setBpm(e.target.value),onBlur:()=>localStorage.setItem('studio_bpm',bpm),className:"w-12 bg-zinc-800 border border-zinc-700 rounded text-xs text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500",min:"40",max:"300"}),/*#__PURE__*/React.createElement("span",{className:"text-xs text-zinc-500"},"BPM")))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"},activeTracks.length===0?/*#__PURE__*/React.createElement("div",{className:"p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-circle",className:"w-8 h-8 text-cyan-400 opacity-80"})),/*#__PURE__*/React.createElement("p",{className:"text-xs font-medium"},"Chưa có Track nào trong dự án."),/*#__PURE__*/React.createElement("button",{onClick:addNewTrack,className:"px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus-circle",className:"w-3.5 h-3.5"}))," Thêm Track Mới")):activeTracks.map((track,idx)=>{const isSelected=selectedTrackId===track.id;return/*#__PURE__*/React.createElement("div",{key:track.id,style:{height:`${track.height||96}px`},className:`shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected?'border-cyan-500 bg-[#252525]':'border-transparent hover:bg-zinc-800/20'}`,onClick:()=>setSelectedTrackId(track.id)},/*#__PURE__*/React.createElement("div",{className:"flex items-start justify-between"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 font-mono"},(idx+1).toString().padStart(2,'0')),/*#__PURE__*/React.createElement("label",{onClick:e=>{e.stopPropagation();const el=e.currentTarget.querySelector('input');if(el)el.click();},className:"cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"color",value:track.color||'#0f766e',onChange:e=>{e.stopPropagation();updateTrackColor(track.id,e.target.value);},className:"w-0 h-0 opacity-0 absolute pointer-events-none"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:track.color}})),editingTrackName===track.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(track.id,editNameInput||track.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(track.id);setEditNameInput(track.name);}},track.name)),/*#__PURE__*/React.createElement("div",{className:"flex flex-wrap gap-0.5 max-w-[100px] mb-0.5"},(track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',name:track.name,startTime:track.startTime}]:[]).slice(0,3).map(c=>/*#__PURE__*/React.createElement("span",{key:c.id,className:"text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700",title:c.name||track.name,onClick:e=>{e.stopPropagation();setSelectedTrackId(track.id);clearLocalSelection();setSelectionMode('global');const start=c.startTime||0;const end=start+(c.buffer?c.buffer.duration:2);setSelectionStart(start);setSelectionEnd(end);showToast(`Selected: ${c.name||track.name}`,'info');}},c.name||track.name),editingClipName&&editingClipName.trackId===track.id&&editingClipName.clipId===c.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);},onKeyDown:e=>{if(e.key==='Enter'){if(editNameInput.trim())updateClipName(track.id,c.id,editNameInput.trim());setEditingClipName(null);}if(e.key==='Escape')setEditingClipName(null);},onClick:e=>e.stopPropagation(),className:"w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none"}):/*#__PURE__*/React.createElement("button",{className:"text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0",title:"Sửa tên clip",onClick:e=>{e.stopPropagation();setEditingClipName({trackId:track.id,clipId:c.id});setEditNameInput(c.name||track.name);}},/*#__PURE__*/React.createElement("i",{"data-lucide":"pencil",className:"w-2.5 h-2.5"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(track.id);},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${track.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"M"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(track.id);},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${soloedTrackId===track.id||track.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"S"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();deleteTrack(track.id);},className:"p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3.5 h-3.5"}))))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-0.5 text-xs",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",value:track.volumeDb??0,onChange:e=>updateTrackVolumeDb(track.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"w-8 text-right text-zinc-500 text-xs"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",value:track.pan??0,onChange:e=>updateTrackPan(track.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-12 text-right font-mono text-zinc-300 text-xs"},track.pan>0?'R'+track.pan:track.pan<0?'L'+Math.abs(track.pan):'C'))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 mt-1",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("input",{type:"file",id:`upload-${track.id}`,accept:"audio/*",className:"hidden",onChange:e=>loadFileOnTrack(track.id,e.target.files[0])}),/*#__PURE__*/React.createElement("label",{htmlFor:`upload-${track.id}`,className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"upload",className:"w-3 h-3"}))," File"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();showToast('FX panel for track '+track.id,'info');},className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-xs font-bold flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"wand-2",className:"w-3 h-3"}))," FX: ",/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 font-normal"},"None")),/*#__PURE__*/React.createElement("button",{onClick:()=>generateSynthToTrack(track.id,'synth'),className:"px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-xs font-bold flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3 h-3"}))," Synth: ",/*#__PURE__*/React.createElement("span",{className:"text-zinc-500 font-normal"},"None"))),/*#__PURE__*/React.createElement("div",{onMouseDown:e=>handleTrackResizeMouseDown(e,track.id),className:"absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",onClick:e=>e.stopPropagation()}));})),/*#__PURE__*/React.createElement("div",{className:"h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0"})),/*#__PURE__*/React.createElement("div",{ref:handleTimelineWrapperRef,onScroll:handleTimelineScroll,className:"flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${timelineWidth}px`},className:"relative flex flex-col min-h-full"},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0"},/*#__PURE__*/React.createElement("div",{ref:rulerRef,className:"flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden",onMouseDown:handleRulerMouseDown},Array.from({length:Math.ceil(maxDuration)}).map((_,i)=>{const sec=i;const x=sec*zoom;return/*#__PURE__*/React.createElement("div",{key:i,className:"absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none",style:{left:`${x}px`}},formatTimeSimple(sec));}))),selectionMode==='global'&&selLeft!==null&&selRight!==null&&selRight>selLeft&&/*#__PURE__*/React.createElement("div",{className:"absolute inset-0 pointer-events-none z-20",style:{left:`${selLeft*zoom}px`,width:`${(selRight-selLeft)*zoom}px`,top:'40px'}},/*#__PURE__*/React.createElement("div",{className:"w-full h-full bg-amber-500/10",style:{borderLeft:'1px solid #f59e0b',borderRight:'1px solid #f59e0b'}})),/*#__PURE__*/React.createElement("div",{className:"sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 relative h-full overflow-hidden bg-[#1a1a2e]"},/*#__PURE__*/React.createElement(TempoTrackLane,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,onPlayheadSet:handlePlayheadSet,snapValue:snapValue,onRulerMouseDown:handleRulerMouseDown,scrollLeft:scrollLeft}))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"},activeTracks.map((track,idx)=>{const isSelected=selectedTrackId===track.id;return/*#__PURE__*/React.createElement("div",{key:track.id,style:{height:`${track.height||96}px`},className:`shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected?'bg-zinc-800/10':''}`,onDragOver:e=>e.preventDefault(),onDrop:e=>{e.preventDefault();if(e.dataTransfer.files[0])loadFileOnTrack(track.id,e.dataTransfer.files[0]);},onMouseEnter:()=>setHoveredTrackId(track.id)},/*#__PURE__*/React.createElement(WaveformLane,{track:track,zoom:zoom,timelineWidth:timelineWidth,viewportWidth:viewportWidth,onSelectRange:handleSelectRange,onPlayheadSet:handlePlayheadSet,isSelected:isSelected,onSelectTrack:setSelectedTrackId,markers:track.markers,onTrackLaneMouseDown:handleTrackLaneMouseDown,onContextMenu:handleContextMenu,onClipDragStart:handleClipDragStart,onClipStretchStart:handleClipStretchStart,onSectionItemDragStart:handleSectionItemDragStart,onSectionItemResizeStart:handleSectionItemResizeStart,onSelectionEdgeDragStart:handleSelectionEdgeDragStart,setSelectedClipId:setSelectedClipId,selectedClipId:selectedClipId,activeTool:activeTool,onSplitTrackAtTime:handleSplitTrackAtTime,onEditClipInSubTab:handleEditClipInSubTab,onEditSectionInTab:handleEditSectionInTab,onEditMidiInTab:handleEditMidiInTab,snapValue:snapValue,bpm:bpm,selectionMode:selectionMode,localSelectionTrackId:localSelectionTrackId,localSelectionStart:localSelectionStart,currentTime:currentTime,getLocalAnchor:()=>localSelectionAnchorRef.current,onClearLocalSelection:clearLocalSelection,onSetSelectionMode:setSelectionMode,onSetSelectionStart:setSelectionStart,onSetSelectionEnd:setSelectionEnd,onSetCurrentTime:setCurrentTime,onSetLocalSelectionTrackId:setLocalSelectionTrackId,onSetLocalSelectionStart:setLocalSelectionStart,onSetLocalSelectionEnd:setLocalSelectionEnd,localSelLeft:localSelectionStart!==null&&localSelectionEnd!==null?Math.min(localSelectionStart,localSelectionEnd):null,localSelRight:localSelectionStart!==null&&localSelectionEnd!==null?Math.max(localSelectionStart,localSelectionEnd):null,scrollLeft:scrollLeft}),selectionMode==='local'&&localSelectionTrackId===track.id&&localSelectionStart!==null&&localSelectionEnd!==null&&Math.abs(localSelectionEnd-localSelectionStart)>0&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",style:{left:`${Math.min(localSelectionStart,localSelectionEnd)*zoom}px`,width:`${Math.abs(localSelectionEnd-localSelectionStart)*zoom}px`}},/*#__PURE__*/React.createElement("div",{className:"absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",onMouseDown:e=>handleHandleDragStart(e,'left')}),/*#__PURE__*/React.createElement("div",{className:"absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",onMouseDown:e=>handleHandleDragStart(e,'right')})),track.buffer&&/*#__PURE__*/React.createElement("div",{className:"absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100"},/*#__PURE__*/React.createElement("button",{onClick:()=>handleSplitTrack(track.id),className:"px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-xs flex items-center gap-1 border border-zinc-700/50"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-2.5 h-2.5 text-cyan-400"}))," Cắt")),/*#__PURE__*/React.createElement("div",{onMouseDown:e=>handleTrackResizeMouseDown(e,track.id),className:"absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",onClick:e=>e.stopPropagation()}));}),/*#__PURE__*/React.createElement("div",{className:"h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800",onMouseEnter:()=>{if(draggedClipRef.current||draggedSectionItemRef.current){setHoveredTrackId(addNewTrack());}},onClick:addNewTrack},/*#__PURE__*/React.createElement("span",{className:"flex items-center gap-1 text-zinc-400"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-3.5 h-3.5"}))," Kéo clip xuống hoặc Click tạo Track")),selectionMode==='global'&&selLeft!==null&&selRight!==null&&selRight>selLeft&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing",style:{left:`${selLeft*zoom}px`,width:`${(selRight-selLeft)*zoom}px`},onMouseDown:handleSelectionBodyDragStart},/*#__PURE__*/React.createElement("div",{className:"absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",onMouseDown:e=>handleHandleDragStart(e,'left')}),/*#__PURE__*/React.createElement("div",{className:"absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",onMouseDown:e=>handleHandleDragStart(e,'right')})),/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none",style:{left:`${playheadLeftPos}px`}},/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"})))))):(()=>{const st=subTabs.find(s=>s.id===activeTab);if(!st)return null;if(st.type==='PIANO_ROLL'){return/*#__PURE__*/React.createElement(PianoRollTabEditor,{st:st,zoom:zoom,bpm:bpm,viewportWidth:viewportWidth,onClose:()=>closeSubTab(st.id),onUpdateNotes:handleUpdateMidiNotes});}const subTrack=tracks.find(t=>t.id===st.trackId);const vTrack=subTrack?{...subTrack,buffer:st.buffer,isSubTab:true}:null;const subTabTimelineWidth=Math.max(zoom*(st.buffer?st.buffer.duration:0),viewportWidth);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",style:{scrollbarWidth:'none',msOverflowStyle:'none'}},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-zinc-500 uppercase"},"Sub-Tab"),/*#__PURE__*/React.createElement("button",{onClick:()=>closeSubTab(st.id),className:"px-1.5 py-0.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))," Close")),vTrack?/*#__PURE__*/React.createElement("div",{key:vTrack.id,className:"flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between mb-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("label",{onClick:e=>{e.stopPropagation();const el=e.currentTarget.querySelector('input');if(el)el.click();},className:"cursor-pointer"},/*#__PURE__*/React.createElement("input",{type:"color",value:vTrack.color||'#0f766e',onChange:e=>{e.stopPropagation();updateTrackColor(vTrack.id,e.target.value);},className:"w-0 h-0 opacity-0 absolute pointer-events-none"}),/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",style:{backgroundColor:vTrack.color}})),editingTrackName===vTrack.id?/*#__PURE__*/React.createElement("input",{type:"text",value:editNameInput,autoFocus:true,onChange:e=>setEditNameInput(e.target.value),onBlur:()=>{updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);},onKeyDown:e=>{if(e.key==='Enter'){updateTrackName(vTrack.id,editNameInput||vTrack.name);setEditingTrackName(null);}if(e.key==='Escape')setEditingTrackName(null);},onClick:e=>e.stopPropagation(),className:"text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none"}):/*#__PURE__*/React.createElement("span",{className:"text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors",title:"Click to rename",onClick:e=>{e.stopPropagation();setEditingTrackName(vTrack.id);setEditNameInput(vTrack.name);}},vTrack.name)),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackMute(vTrack.id);},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.muted?'bg-red-950 text-red-400 border-red-700':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"M"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleTrackSoloEvaluate(vTrack.id);},className:`px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${soloedTrackId===vTrack.id||vTrack.solo?'bg-amber-950 text-amber-400 border-amber-600':'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`},"S"))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-2.5 text-[14px] mb-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Vol:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-50",max:"7",step:"0.5",id:`tcp-vol-${st.id}`,value:vTrack.volumeDb??0,onChange:e=>updateTrackVolumeDb(vTrack.id,parseFloat(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-vol-label-${st.id}`},vTrack.volumeDb??0,"dB")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Pan:"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",step:"1",id:`tcp-pan-${st.id}`,value:vTrack.pan??0,onChange:e=>updateTrackPan(vTrack.id,parseInt(e.target.value)),className:"flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500",style:{height:'4px'}}),/*#__PURE__*/React.createElement("span",{className:"w-16 text-right font-mono text-zinc-300 text-[14px]",id:`tcp-pan-label-detailed-${st.id}`},vTrack.pan>0?'R'+vTrack.pan:vTrack.pan<0?'L'+Math.abs(vTrack.pan):'C')),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5 text-[14px]"},/*#__PURE__*/React.createElement("button",{onClick:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,isLooping:!s.isLooping}:s)),className:`px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping?'bg-amber-700 text-white border-amber-500':'bg-zinc-800 text-zinc-400 border-zinc-700'}`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>updateSubTabEffects(st.id,{reverse:!(st.effects||{}).reverse}),className:`px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects||{}).reverse?'bg-zinc-600 text-white border-zinc-500':'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`,title:"Reverse"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"arrow-left-right",className:"w-3.5 h-3.5"}))),/*#__PURE__*/React.createElement("span",{className:"w-10 text-right text-zinc-500 text-[14px]"},"Loop:"),/*#__PURE__*/React.createElement("input",{type:"number",min:"0",max:"999",value:st.loopCount||0,onChange:e=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,loopCount:Math.max(0,parseInt(e.target.value)||0)}:s)),className:"w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono",title:"Loop count"}))),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 justify-between my-2.5"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Normalize"},"Norm"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:subTabNormVal,onChange:e=>setSubTabNormVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabNormVal,"dB"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'normalize',subTabNormVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Pitch Shift"},"Pitch"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"12",step:"0.5",value:subTabPitchVal,onChange:e=>setSubTabPitchVal(parseFloat(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabPitchVal>0?'+':'',subTabPitchVal,"st"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'pitch',subTabPitchVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply")),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"},/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",title:"Gain Multiplier"},"Gain"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0",max:"150",step:"1",value:subTabGainVal,onChange:e=>setSubTabGainVal(parseInt(e.target.value)),style:{writingMode:'vertical-lr',direction:'rtl',height:'120px',width:'20px',accentColor:'#a1a1aa'},className:"my-2 cursor-pointer"}),/*#__PURE__*/React.createElement("span",{className:"text-[14px] font-mono text-zinc-300 font-bold"},subTabGainVal,"%"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTabEffect(st.id,'gain',subTabGainVal),className:"mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650"},"Apply"))),/*#__PURE__*/React.createElement("div",{className:"flex-1"}),/*#__PURE__*/React.createElement("div",{className:"mt-auto pt-2.5 border-t border-zinc-800"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"Duration:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?formatTime(st.buffer.duration):'0s')),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase"},/*#__PURE__*/React.createElement("span",null,"SR:"),/*#__PURE__*/React.createElement("span",{className:"font-mono text-zinc-300"},st.buffer?st.buffer.sampleRate:0," Hz")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1 mb-2"},/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAIScan();},disabled:analysisState.isRunning,className:"py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"map-pin",className:"w-3 h-3"}))," Scan"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;handleAICutToNewTrack();},disabled:analysisState.isRunning,className:"py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs flex items-center justify-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3 h-3"}))," Cut"),/*#__PURE__*/React.createElement("button",{onClick:async e=>{subTabAiTrackIdRef.current=st.trackId;if(st.buffer){setSelectionRangeOnBuffer(st.buffer,st.selectionStart||0,st.selectionEnd||st.buffer.duration);}handleAIAnalysicLoop();},disabled:analysisState.isRunning,className:"py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1 col-span-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sparkles",className:"w-3 h-3"}))," AI Analysic Loop")),/*#__PURE__*/React.createElement("button",{onClick:()=>exportSubTabBuffer(st.id),className:"w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"download",className:"w-4 h-4"}))," Export"),/*#__PURE__*/React.createElement("button",{onClick:()=>applySubTab(st.id),className:"w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-4 h-4"}))," Save"))):/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center justify-center text-xs text-zinc-500"},"Track not found")),/*#__PURE__*/React.createElement("div",{ref:handleTimelineWrapperRef,className:"flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"},/*#__PURE__*/React.createElement("div",{style:{width:`${subTabTimelineWidth}px`},className:"relative flex flex-col min-h-full"},/*#__PURE__*/React.createElement("div",{className:"sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden",onMouseDown:handleRulerMouseDown},Array.from({length:Math.ceil(st.buffer?st.buffer.duration:0)}).map((_,i)=>{const sec=i;const x=sec*zoom;return/*#__PURE__*/React.createElement("div",{key:i,className:"absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none",style:{left:`${x}px`}},formatTimeSimple(sec));}))),/*#__PURE__*/React.createElement("div",{className:"sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 relative h-full overflow-hidden bg-[#1a1a2e]"},/*#__PURE__*/React.createElement(TempoTrackLane,{bpm:parseInt(bpm)||120,zoom:zoom,timelineWidth:subTabTimelineWidth,onPlayheadSet:setCurrentTime,snapValue:snapValue}))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col relative bg-[#111111] min-h-full"},vTrack&&/*#__PURE__*/React.createElement("div",{style:{height:`${subTabHeight}px`},className:"relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0"},/*#__PURE__*/React.createElement(SubTabWaveform,{buffer:st.buffer,subTabId:st.id,activeTab:activeTab,activeTool:activeTool,currentTime:st.currentTime,selectionStart:st.selectionStart,selectionEnd:st.selectionEnd,onSelectRange:(start,end)=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,selectionStart:start,selectionEnd:end}:s)),onPlayheadSet:time=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:time}:s)),onContextMenu:(e,clickTime)=>setContextMenu({x:e.clientX,y:e.clientY,isSubTab:true,subTabId:st.id,time:clickTime}),zoom:zoom,timelineWidth:subTabTimelineWidth,color:vTrack.color,name:vTrack.name,speed:st.speed||1.0,volumeNodes:st.volumeNodes||[],panningNodes:st.panningNodes||[],fadeInLen:st.fadeInLen||0,fadeOutLen:st.fadeOutLen||0,graphMode:st.graphMode,channelInfo:st.channelInfo,selectedNodeTime:subTabSelectedNodeTime,setSelectedNodeTime:setSubTabSelectedNodeTime,onUpdateNodes:nodes=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,[s.graphMode==='pan'?'panningNodes':'volumeNodes']:nodes}:s)),onUpdateFade:fade=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,fadeInLen:fade.fadeInLen??s.fadeInLen,fadeOutLen:fade.fadeOutLen??s.fadeOutLen}:s)),onModeToggle:()=>setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,graphMode:s.graphMode==='pan'?null:'pan'}:s)),onSpeedChange:newSpeed=>{setSubTabs(prev=>prev.map(s=>{if(s.id!==st.id)return s;const oldSpeed=s.speed||1.0;const ratio=oldSpeed/newSpeed;const newVolumeNodes=(s.volumeNodes||[]).map(n=>({...n,time:n.time*ratio}));const newPanningNodes=(s.panningNodes||[]).map(n=>({...n,time:n.time*ratio}));return{...s,speed:newSpeed,volumeNodes:newVolumeNodes,panningNodes:newPanningNodes,fadeInLen:(s.fadeInLen||0)*ratio,fadeOutLen:(s.fadeOutLen||0)*ratio,currentTime:(s.currentTime||0)*ratio,label:s.label.replace(/\s\(\d+%\)$/,'')+` (${Math.round(newSpeed*100)}%)`};}));const n=activeTrackNodesRef.current[st.trackId];if(n&&n.source)n.source.playbackRate.value=newSpeed;// Reset time refs to prevent playhead jump when speed changes mid-playback +const ctx=getAudioContext();const elapsed=ctx.currentTime-startAudioTimeRef.current;const oldSpeed=activePlaybackSpeedRef.current;const ratio=oldSpeed/newSpeed;startBufferOffsetRef.current=startBufferOffsetRef.current+elapsed*oldSpeed;startOffsetTimeRef.current=(startOffsetTimeRef.current+elapsed)*ratio;startAudioTimeRef.current=ctx.currentTime;activePlaybackSpeedRef.current=newSpeed;}}),/*#__PURE__*/React.createElement("div",{onMouseDown:handleSubTabResizeMouseDown,className:"absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors"})),st.selectionStart!==null&&st.selectionEnd!==null&&st.selectionEnd>st.selectionStart&&/*#__PURE__*/React.createElement("div",{className:"absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none",style:{left:`${Math.min(st.selectionStart,st.selectionEnd)*zoom}px`,width:`${Math.abs(st.selectionEnd-st.selectionStart)*zoom}px`}})))));})())),/*#__PURE__*/React.createElement("div",{className:"w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",onMouseDown:startColResize}),renderDock('right','Right')),renderDock('bottom','Bottom'));})(),/*#__PURE__*/React.createElement("div",{className:"h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-4"},/*#__PURE__*/React.createElement("span",null,"Status: ",isPlaying?'Playing':'Stopped'),activeTab!=='main'&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-semibold uppercase"},"Sub-Tab"),activeTab==='main'&&/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-semibold uppercase"},"Track: ID ",selectedTrackId),selectionMode==='local'&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-semibold uppercase text-xs"},"Local Sel"),selectionMode==='global'&&/*#__PURE__*/React.createElement("span",{className:"text-purple-400 font-semibold uppercase text-xs"},"Global Sel"),soloedTrackId&&/*#__PURE__*/React.createElement("span",{className:"text-amber-500 font-semibold"},"Solo: ID ",soloedTrackId),isLoopingSelection&&selectionMode==='local'&&/*#__PURE__*/React.createElement("span",{className:"text-emerald-400 font-semibold uppercase text-xs"},"Solo Loop"),isLoopingSelection&&selectionMode!=='local'&&/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-semibold uppercase text-xs"},"Master Loop")),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setShowExportPanel(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showExportPanel?'bg-cyan-900 text-cyan-300':'text-zinc-500 hover:text-zinc-300'}`,title:`Export Panel (${panelPositions.export})`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"save",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showExportPanel?panelPositions.export[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowSelectionPanel(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showSelectionPanel?'bg-amber-900 text-amber-300':'text-zinc-500 hover:text-zinc-300'}`,title:`Selection Panel (${panelPositions.selection})`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showSelectionPanel?panelPositions.selection[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowAIPanel(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showAIPanel?'bg-purple-900 text-purple-300':'text-zinc-500 hover:text-zinc-300'}`,title:`AI Panel (${panelPositions.ai})`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"cpu",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showAIPanel?panelPositions.ai[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowFxRack(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showFxRack?'bg-rose-900 text-rose-300':'text-zinc-500 hover:text-zinc-300'}`,title:`FX Rack Panel (${panelPositions.fx_rack||'bottom'})`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showFxRack?(panelPositions.fx_rack||'bottom')[0].toUpperCase():'')),/*#__PURE__*/React.createElement("button",{onClick:()=>setShowMidiEvents(p=>!p),className:`px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMidiEvents?'bg-sky-900 text-sky-300':'text-zinc-500 hover:text-zinc-300'}`,title:`MIDI Events Panel (${panelPositions.midi_events||'bottom'})`},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"music",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("span",{className:"text-[7px] opacity-60"},showMidiEvents?(panelPositions.midi_events||'bottom')[0].toUpperCase():'')),/*#__PURE__*/React.createElement("span",{className:"w-[1px] h-3 bg-zinc-800 mx-1"}),/*#__PURE__*/React.createElement("span",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"info",className:"w-3 h-3 text-zinc-600"}))," Scroll: Zoom"),/*#__PURE__*/React.createElement("span",null,"|"),/*#__PURE__*/React.createElement("span",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"keyboard",className:"w-3 h-3 text-zinc-600"}))," Ctrl+Scroll: Playhead"))),contextMenu&&(contextMenu.isSubTab?/*#__PURE__*/React.createElement("div",{className:"fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64",style:{left:contextMenu.x,top:contextMenu.y},onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"px-3 py-1 text-xs text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1"},"Selection: ",formatTime(subTabs.find(s=>s.id===contextMenu.subTabId)?.selectionStart||0)," - ",formatTime(subTabs.find(s=>s.id===contextMenu.subTabId)?.selectionEnd||0)),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabCut(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-rose-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Cut"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+X")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabCopy(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5 text-zinc-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Copy"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+C")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabPaste(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Paste"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+V")),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabDelete(contextMenu.subTabId);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3.5 h-3.5 text-red-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Delete Selected Segment"),/*#__PURE__*/React.createElement("span",{className:"text-amber-400 text-xs font-semibold font-mono ml-auto"},"Del")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:()=>{handleSubTabLoop(contextMenu.subTabId,4);closeContextMenu();},className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"repeat",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Loop Selection 4 times"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto"},"Ctrl+L"))):/*#__PURE__*/React.createElement("div",{className:"fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64",style:{left:contextMenu.x,top:contextMenu.y},onClick:e=>e.stopPropagation()},contextMenu.sectionId?/*#__PURE__*/React.createElement("button",{onClick:contextMenuEditSection,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-edit",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Edit Section"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+E")):/*#__PURE__*/React.createElement("button",{onClick:contextMenuEdit,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"file-edit",className:"w-3.5 h-3.5 text-amber-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Edit"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+E")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuSplit,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-cyan-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Split"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"S")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuMerge,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"combine",className:"w-3.5 h-3.5 text-purple-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Merge"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+M")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:contextMenuCopy,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"copy",className:"w-3.5 h-3.5 text-zinc-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Copy"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+C")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuCut,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"scissors",className:"w-3.5 h-3.5 text-rose-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Cut"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+X")),/*#__PURE__*/React.createElement("button",{onClick:contextMenuPaste,className:"w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"clipboard",className:"w-3.5 h-3.5 text-emerald-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Paste"),/*#__PURE__*/React.createElement("span",{className:"text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"},"Ctrl+V")),/*#__PURE__*/React.createElement("div",{className:"h-px bg-zinc-700 my-1"}),/*#__PURE__*/React.createElement("button",{onClick:contextMenuDelete,className:"w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"trash-2",className:"w-3.5 h-3.5 text-red-400 shrink-0"})),/*#__PURE__*/React.createElement("span",{className:"flex-1"},"Delete"),/*#__PURE__*/React.createElement("span",{className:"text-amber-400 text-xs font-semibold font-mono ml-auto pl-8"},"Del")))),toastMessage&&/*#__PURE__*/React.createElement("div",{className:"absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800"},/*#__PURE__*/React.createElement("span",{className:"inline-flex items-center shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"bell",className:`w-4 h-4 ${toastMessage.type==='success'?'text-emerald-400':toastMessage.type==='error'?'text-rose-400':toastMessage.type==='warning'?'text-amber-400':'text-cyan-400'}`})),toastMessage.text,toastMessage.onActionClick&&/*#__PURE__*/React.createElement("button",{onClick:()=>{toastMessage.onActionClick();setToastMessage(null);},className:"ml-2 px-2 py-0.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-[10px] uppercase font-bold"},toastMessage.actionText||'Tải về'))),appWarningModal&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"},/*#__PURE__*/React.createElement("div",{className:"bg-[#222222] border border-zinc-800 rounded-lg shadow-2xl p-6 max-w-md w-full text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 mb-3 text-cyan-400 font-bold"},/*#__PURE__*/React.createElement("h3",{className:"text-base font-bold"},appWarningModal.title)),/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-300 mb-5 leading-relaxed whitespace-pre-line"},appWarningModal.message),/*#__PURE__*/React.createElement("div",{className:"flex justify-end gap-2"},appWarningModal.isAlert?/*#__PURE__*/React.createElement("button",{onClick:()=>setAppWarningModal(null),className:"px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition"},"Đóng"):/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("button",{onClick:()=>setAppWarningModal(null),className:"px-4 py-2 bg-zinc-700 hover:bg-zinc-600 active:bg-zinc-800 text-slate-300 rounded text-xs font-semibold transition"},"Hủy"),/*#__PURE__*/React.createElement("button",{onClick:()=>{const fn=appWarningModal.onConfirm;setAppWarningModal(null);if(fn)fn();},className:"px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition"},"Xác nhận"))))),/*#__PURE__*/React.createElement(AuthModal,{isOpen:authModalOpen,mode:authMode,forceMandatory:isMandatoryLogin,onClose:()=>setAuthModalOpen(false),onSuccess:handleAuthSuccess}),/*#__PURE__*/React.createElement(ProfileModal,{isOpen:profileModalOpen,onClose:()=>setProfileModalOpen(false),tracks:tracks,setTracks:setTracks,setSelectedTrackId:setSelectedTrackId,projectName:projectName,setProjectName:setProjectName,currentProjectId:currentProjectId,setCurrentProjectId:setCurrentProjectId,showToast:showToast}),/*#__PURE__*/React.createElement(SaveProjectModal,{isOpen:saveProjectModalOpen,onClose:()=>setSaveProjectModalOpen(false),onSave:newName=>{handleSaveProjectWithName(newName);}}),/*#__PURE__*/React.createElement(SaveAsModal,{isOpen:saveAsModalOpen,onClose:()=>setSaveAsModalOpen(false),projectName:projectName,onSaveCloud:newName=>{handleSaveAsCloud(newName);},onSaveLocal:newName=>{handleExportSFS(newName);setProjectName(newName);localStorage.setItem('sonic_project_name',newName);}}),/*#__PURE__*/React.createElement(AIConfigModal,{isOpen:aiConfigModalOpen,onClose:()=>setAiConfigModalOpen(false),onConfigSaved:providers=>{setAiProviders(providers);const active=providers.find(p=>p.is_active)||providers[0];if(active)setSelectedProviderId(active.id);}}),/*#__PURE__*/React.createElement(SystemManagerModal,{isOpen:systemManagerModalOpen,onClose:()=>setSystemManagerModalOpen(false)}));};const root=ReactDOM.createRoot(document.getElementById('root'));root.render(/*#__PURE__*/React.createElement(App,null));setTimeout(()=>lucide.createIcons(),300); diff --git a/app/static/js/services/storage.js b/app/static/js/services/storage.js index 55e7b76..a0e0211 100644 --- a/app/static/js/services/storage.js +++ b/app/static/js/services/storage.js @@ -3,12 +3,11 @@ const SFS_VERSION = "1.0.0"; function exportProjectToSFS(projectState) { - const sfsBundle = { - format: "SONICFORGE_STUDIO_PROJECT", - version: SFS_VERSION, - timestamp: Date.now(), - domain: window.location.origin, - project: { + let projectObj = {}; + if (projectState.main_session) { + projectObj = projectState; + } else { + projectObj = { id: projectState.id || `proj_${Date.now()}`, name: projectState.name || "Dự án mới", tracks: (projectState.tracks || []).map(t => ({ @@ -24,7 +23,15 @@ markers: t.markers || [], serverFileId: t.serverFileId || null })) - } + }; + } + + const sfsBundle = { + format: "SONICFORGE_STUDIO_PROJECT", + version: SFS_VERSION, + timestamp: Date.now(), + domain: window.location.origin, + project: projectObj }; const jsonStr = JSON.stringify(sfsBundle, null, 2); @@ -33,7 +40,8 @@ const a = document.createElement('a'); a.href = url; - a.download = `${(projectState.name || 'project').replace(/\s+/g, '_')}.sfs`; + const displayName = projectObj.metadata?.title || projectObj.name || 'project'; + a.download = `${displayName.replace(/\s+/g, '_')}.sfs`; a.click(); URL.revokeObjectURL(url); } @@ -53,7 +61,7 @@ autoSaveTimer = setTimeout(async () => { try { const state = getProjectStateCallback(); - if (!state || !state.tracks || state.tracks.length === 0) return; + if (!state || (!state.tracks && !state.main_session)) return; const dataJson = JSON.stringify(state); localStorage.setItem('sonic_temp_project', dataJson); if (window.SonicAPI && localStorage.getItem('sonic_token')) { diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 2ff350819995fb75a293acc2693204c156870512..e6141a2c32478e9a341652aae18558efc4fb64b5 100644 GIT binary patch delta 912 zcmb`F&ui2`6vvZPkgaypdT>Fch9Fqcw3FY79&AMqJy8#%wG{ig9NJ&6qS;+2>wXweMLF5A5N$IeMNaD$g}Cn@lSa=dv{Iw1s=i;x&QzG delta 1763 zcmcJPPe>F|9LHy73myOLW)OnewoFBuJND+!&djcd)sVnbC@vjDbmq+)cXM`jnVpp& z)^R~R1X||7j#(WI-Mm?cDDYP25c(rTMr)!TG$Pm?&54P4*6m; zS(j+;DmFIP*3eMYMIiz>7O4@D6(zx76<&g@j%xu>B7zQiRZpj^^3t8lSSe^^>@n1t zbMksB!$mkGN1~z;Q6w2?91@~}q6r4i3sGGL2t_4eK#9k42CHivXJ}9turiMnAd69> ztlP7>ID78xyJvL-K~RAeBCry8zt#kv1p3>239_2|QI~y*sxI>!EMFb1Mv>e-)j^&} zP-HbNY9qQ2c^^A=?~RS?gpZ!X?V;!DADo=}`IU;dX?L2erxS@YGySzxjRFK&SBwbD zi5&3WG|DQRB(o7siRw_}pn%D!;@t-_Idq3f=}e3{O49W-nJ{CiX@t|gO1yJy6^`ea z*ic1<+s_#EjzYkTc}T1FII=wx!#Ek)6Ue@vGEpB^u=1uEW?)jaEM)ds z2G;RHg>c0#f5@&E`K4X=FZ~|0%jqK0m4CB4Tp%B-J(~}uuM-0|r!O_oBZ>G7qNy4O zrL!Z|JajcV3wI~ax@8v1vRONG3qfbUL%mxsN})GPR>;{}UrL8ya${8unH#HloN` [Section Store] ---> [Item Clip Metadata] | | +| +-----------------------------------------------------------------------------+ | +| | | +| +-----------------------------------------------------------------------------+ | +| | Audio & Clock Engine | | +| | +------------------------+ +------------------+ +---------------------+ | | +| | | Precision Scheduler | | Web Audio Graph | | AudioWorklet Synth | | | +| | | (Lookahead Timer) | | AudioNode Router | | / WebAssembly Core | | | +| | +------------------------+ +------------------+ +---------------------+ | | +| +-----------------------------------------------------------------------------+ | ++------------------------------------------^----------------------------------------+ + | WebSocket / REST API ++------------------------------------------v----------------------------------------+ +| BACKEND SERVER (PYTHON) | +| +-----------------------------------------------------------------------------+ | +| | FastAPI / WebSocket Handler | | +| +-----------------------------------------------------------------------------+ | +| | DSP Engine (Pedalboard / Numpy / Scipy) - Offline Render, Audio Export | | +| +-----------------------------------------------------------------------------+ | +| | VST / VSTi Hosting Bridge & Plugin State Persistence | | +| +-----------------------------------------------------------------------------+ | ++-----------------------------------------------------------------------------------+ + +``` + +--- + +### 2. Detailed Data Schemas (JSON Specification) + +#### 2.1 Project Root Schema (`project_schema.json`) + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DAWProject", + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "metadata": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "bpm": { "type": "number", "minimum": 20.0, "maximum": 999.0, "default": 120.0 }, + "time_signature_numerator": { "type": "integer", "default": 4 }, + "time_signature_denominator": { "type": "integer", "default": 4 }, + "sample_rate": { "type": "integer", "default": 44100 } + }, + "required": ["title", "bpm", "time_signature_numerator", "time_signature_denominator", "sample_rate"] + }, + "main_session": { "$ref": "#/definitions/SessionContainer" }, + "section_store": { + "type": "object", + "description": "Auxiliary registry mapping section_id to sub-session containers", + "additionalProperties": { "$ref": "#/definitions/SessionContainer" } + } + }, + "required": ["project_id", "metadata", "main_session", "section_store"], + "definitions": { + "SessionContainer": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "is_root": { "type": "boolean" }, + "length_bars": { "type": "number", "description": "Computed or manually set total length in bars" }, + "auto_compute_length": { "type": "boolean", "default": true }, + "tracks": { + "type": "array", + "items": { "$ref": "#/definitions/Track" } + } + }, + "required": ["id", "is_root", "tracks"] + }, + "Track": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] }, + "volume_db": { "type": "number", "default": 0.0 }, + "pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 }, + "mute": { "type": "boolean", "default": false }, + "solo": { "type": "boolean", "default": false }, + "fx_chain": { + "type": "array", + "items": { "$ref": "#/definitions/FXPlugin" } + }, + "synth_engine": { "$ref": "#/definitions/SynthPlugin" }, + "items": { + "type": "array", + "items": { "$ref": "#/definitions/TimelineItem" } + } + }, + "required": ["id", "name", "type", "items"] + }, + "TimelineItem": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "type": { "type": "string", "enum": ["AUDIO_ITEM", "MIDI_ITEM", "SECTION_ITEM"] }, + "start_bar": { "type": "number", "description": "Global timeline position where the item starts" }, + "duration_bars": { "type": "number", "description": "Visible duration on the track timeline in bars" }, + "clip_start_offset_bars": { "type": "number", "description": "Internal start offset inside the source buffer/item" }, + "source_data": { + "type": "object", + "oneOf": [ + { "$ref": "#/definitions/AudioSourceData" }, + { "$ref": "#/definitions/MIDISourceData" }, + { "$ref": "#/definitions/SectionSourceData" } + ] + } + }, + "required": ["id", "type", "start_bar", "duration_bars", "clip_start_offset_bars", "source_data"] + }, + "AudioSourceData": { + "type": "object", + "properties": { + "audio_file_url": { "type": "string" }, + "sample_rate": { "type": "integer" }, + "channels": { "type": "integer" }, + "gain": { "type": "number", "default": 1.0 } + }, + "required": ["audio_file_url"] + }, + "MIDISourceData": { + "type": "object", + "properties": { + "total_buffer_bars": { "type": "number", "default": 8.0 }, + "notes": { + "type": "array", + "items": { "$ref": "#/definitions/MIDINote" } + } + }, + "required": ["total_buffer_bars", "notes"] + }, + "SectionSourceData": { + "type": "object", + "properties": { + "referenced_section_id": { "type": "string", "description": "Pointer to section_store key" } + }, + "required": ["referenced_section_id"] + }, + "MIDINote": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "pitch": { "type": "integer", "minimum": 0, "maximum": 127 }, + "start_beat": { "type": "number", "description": "Beat offset relative to the start of the source buffer (bar 0)" }, + "duration_beats": { "type": "number" }, + "velocity": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": 0.8 }, + "pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 } + }, + "required": ["id", "pitch", "start_beat", "duration_beats", "velocity"] + }, + "FXPlugin": { + "type": "object", + "properties": { + "plugin_id": { "type": "string" }, + "name": { "type": "string" }, + "bypass": { "type": "boolean", "default": false }, + "parameters": { "type": "object" } + } + }, + "SynthPlugin": { + "type": "object", + "properties": { + "plugin_id": { "type": "string" }, + "preset_id": { "type": "string" }, + "parameters": { "type": "object" } + } + } + } +} + +``` + +--- + +### 3. UI, Tab Navigation & View State Management + +#### 3.1 Tab Context Model, Pinning Rules & Close Prevention Hierarchy + +The application manages view tabs dynamically while maintaining strict lifecycle integrity: + +* **Main Session Tab (Fixed / Pinned):** Always pinned at index 0 (`is_closeable: false`). It cannot be closed under any circumstances. +* **Sub-Tabs (Section-Tab, Piano Roll Tab, Audio Sample Editor Sub-Tab):** Dynamic views (`is_closeable: true`). +* **Parent-Child Tab Dependency Rules:** +* A Section-Tab represents an intermediate sub-session. +* When a user opens a child item (e.g., a `MIDIItem` or `AudioItem` inside a Section-Tab) into a Piano Roll Tab or Audio Sample Editor Sub-Tab, a parent-child context lineage is registered. +* **Close Block Rule:** A Section-Tab cannot be closed while any of its child items are currently open in active sub-tabs. Attempting to close the parent Section-Tab displays a block notice highlighting open child editors. + + + +```text + +---------------------------------------+ + | Tab Navigation Controller | + +-------------------+-------------------+ + | + +--------------------------------+--------------------------------+ + | (Pinned / Uncloseable) | (Dynamic / Closable) | (Dynamic / Closable) ++--------v--------+ +--------v--------+ +--------v--------+ +| MAIN SESSION | | SECTION TAB | | PIANO ROLL TAB | +| (Root Context) | | (Sub-Session) | | (Item Context) | +| | | [Parent Context] | [Child Context]| ++-----------------+ +--------+--------+ +--------+--------+ + | | + +---- Depends on child closure ---+ + +``` + +##### State Object Schema with Tab Dependency Tracking: + +```json +{ + "active_tab_id": "tab_pr_1", + "open_tabs": [ + { + "tab_id": "tab_root", + "title": "MAIN SESSION", + "type": "MAIN_SESSION", + "target_id": "main", + "is_closeable": false, + "parent_tab_id": null + }, + { + "tab_id": "tab_sec_1", + "title": "Section: Verse 1", + "type": "SECTION_TAB", + "target_id": "Section_01", + "is_closeable": true, + "parent_tab_id": "tab_root" + }, + { + "tab_id": "tab_pr_1", + "title": "Piano Roll: Bassline", + "type": "PIANO_ROLL", + "target_id": "ItemMIDI_Bassline", + "is_closeable": true, + "parent_tab_id": "tab_sec_1" + } + ], + "piano_roll_state": { + "target_item_id": "ItemMIDI_Bassline", + "viewport_start_bar": 0.0, + "viewport_bar_width": 8.0, + "scroll_y_pitch": 60, + "snap_resolution": "1/16", + "note_selection": [] + } +} + +``` + +#### 3.2 Piano Roll View Canvas Layout & Interaction Spec + +* **Top Navigation Rule Pane (Bars/Beats Bar):** +* Displays bars from $0$ to $N$ (where $N = \text{total\_buffer\_bars}$, e.g., 8 bars). +* Highlights active clip visibility bounds (e.g., Bar 4.0 to Bar 6.0 shaded with active overlay, exterior bars dimmed). + + +* **Left Piano Keybed:** +* Anchored vertically, spans pitches $0$ (C-1) through $127$ (G9). +* Draws standard 88 key / 128 key pattern with distinct black key visually offset bars and pitch labeling ($C3$, $C4$, etc.). + + +* **Note Grid Canvas (Right Pane):** +* Synced to vertical pitch scroll and horizontal beat zoom. +* **Row Background Rendering:** Black key rows are assigned darker background fill color `#1A1A1E`, white key rows use `#25252A`. +* **Snap Grid Lines:** Rendered dynamically based on selected snap mode: Free, 1/1 Bar, 1/2 Beat, 1/4 Beat, 1/8 Beat, 1/16 Beat, 1/32 Beat. + + +* **Bottom Controller Pane (CC / Velocity / Pan Lane):** +* Synchronized horizontally with note grid. +* Displays vertical stem bars per note representing properties (Velocity, Pan). Allows click-and-drag line shaping or direct stem adjustment. + + + +--- + +### 4. Audio & Synth Engine Routing Architecture (Web Audio API) + +#### 4.1 Real-Time Signal Flow Graph + +```text +[MIDI Scheduler] ---> [AudioWorklet / Virtual Synth Engine] + | + v (Audio Buffer / Stream) +[Audio Sample Playback Node] ----> [Track Channel FX Chain] + | + v + [Track Gain / Pan Node] + | + v + +---------------------+---------------------+ + | | + v (If inside Section) v (If Direct Track) +[Section Sub-Mix Bus] [Main Master Mixer Bus] + | | + +-------------------->----------------------+ + | + v + [Web Audio Destination] + +``` + +#### 4.2 Web Audio Node Architecture Specifications + +* **AudioTrack Node Structure:** +```javascript +TrackAudioGraph = { + inputNode: GainNode, + fxChain: [ BiquadFilterNode, DelayNode, ConvolverNode ], + panNode: StereoPannerNode, + outputGainNode: GainNode, + connect(destination) { ... } +} + +``` + + +* **Section Bus Graph Routing:** +* Each Section in Section-tab Store instantiates an intermediate `GainNode` sub-mixer (`SectionBus`). +* Tracks within the Section connect their final outputs to `SectionBus`. +* When a `SectionItem` is placed on a Main Session track, the `SectionBus` output is routed into the Main Session track's input node, preserving non-destructive DSP processing hierarchies. + + + +--- + +### 5. Core Mathematical & Technical Algorithms + +#### 5.1 Algorithm 1: Non-Destructive Item Slicing & Offset Playback Math + +##### Mathematical Formulation + +Let: + +* $T_{\text{global}}$ = Current global playback time in seconds on the main timeline. +* $\text{BPM}$ = Beats Per Minute of the project. +* $\text{TS}_{\text{num}}$ = Time Signature Numerator (e.g., 4 beats per bar). +* $S_{\text{item}}$ = Item start position in global bars ($\text{start\_bar}$). +* $L_{\text{item}}$ = Item visible length on timeline in bars ($\text{duration\_bars}$). +* $O_{\text{item}}$ = Source internal start offset in bars ($\text{clip\_start\_offset\_bars}$). + +Bar to Time Conversion Factor: + +$$\text{SecondsPerBeat} = \frac{60.0}{\text{BPM}}$$ + +$$\text{SecondsPerBar} = \text{SecondsPerBeat} \times \text{TS}_{\text{num}}$$ + +Item Global Time Bounds: + +$$T_{\text{start}} = S_{\text{item}} \times \text{SecondsPerBar}$$ + +$$T_{\text{end}} = (S_{\text{item}} + L_{\text{item}}) \times \text{SecondsPerBar}$$ + +Active Playback Slicing Condition: An item is active if and only if: + +$$T_{\text{start}} \le T_{\text{global}} < T_{\text{end}}$$ + +Local Item Buffer Time Mapping ($T_{\text{local}}$): When $T_{\text{global}}$ falls within $[T_{\text{start}}, T_{\text{end}}]$, the corresponding time $T_{\text{local\_bars}}$ relative to the internal source clip buffer (0 to $\text{BufferLength}$) is: + +$$T_{\text{local\_bars}} = \frac{T_{\text{global}} - T_{\text{start}}}{\text{SecondsPerBar}} + O_{\text{item}}$$ + +MIDI Note Slicing & Filtering Rule: For a MIDI note $N$ inside the item source with start beat $N_{\text{start\_beat}}$ and length $N_{\text{dur\_beat}}$ (converted to internal bar metric $N_{\text{bar\_start}} = \frac{N_{\text{start\_beat}}}{\text{TS}_{\text{num}}}$, $N_{\text{bar\_dur}} = \frac{N_{\text{dur\_beat}}}{\text{TS}_{\text{num}}}$): + +The note is triggered during main playback if and only if: + +$$N_{\text{bar\_start}} \ge O_{\text{item}} \quad \text{AND} \quad N_{\text{bar\_start}} < (O_{\text{item}} + L_{\text{item}})$$ + +##### Pseudocode Implementation + +```javascript +function getActiveMIDINotesForPlayback(item, currentGlobalBar, timeSigNum) { + const itemStartBar = item.start_bar; + const itemEndBar = item.start_bar + item.duration_bars; + const offsetBar = item.clip_start_offset_bars; + + // Check if playback cursor is inside visible item clip + if (currentGlobalBar < itemStartBar || currentGlobalBar >= itemEndBar) { + return []; // Item inactive + } + + const activeNotes = []; + const internalWindowStartBar = offsetBar; + const internalWindowEndBar = offsetBar + item.duration_bars; + + for (const note of item.source_data.notes) { + const noteStartBar = note.start_beat / timeSigNum; + const noteEndBar = noteStartBar + (note.duration_beats / timeSigNum); + + // Filter notes outside the non-destructive visible window + if (noteStartBar >= internalWindowStartBar && noteStartBar < internalWindowEndBar) { + // Calculate playback time relative to global session + const relativeBarInItem = noteStartBar - internalWindowStartBar; + const targetGlobalBar = itemStartBar + relativeBarInItem; + + activeNotes.push({ + note: note, + scheduledGlobalBar: targetGlobalBar + }); + } + } + return activeNotes; +} + +``` + +#### 5.2 Algorithm 2: Dynamic Section Length Calculation Algorithm + +When `auto_compute_length` is enabled for a Section, its total duration in bars $L_{\text{section}}$ is dynamically evaluated from the boundary bounds of all child items across all tracks inside that Section. + +##### Mathematical Formulation + +Let $T$ be the set of tracks in the section, and $I(t)$ be the set of items in track $t$. + +$$L_{\text{section}} = \max_{t \in T} \left( \max_{i \in I(t)} \left( i.\text{start\_bar} + i.\text{duration\_bars} \right) \right)$$ + +If $I(t)$ is empty for all $t$, then $L_{\text{section}} = 4.0$ (default baseline minimum). + +##### Implementation Architecture + +```javascript +function recomputeSectionLength(sectionContainer) { + if (!sectionContainer.auto_compute_length) { + return sectionContainer.length_bars; + } + + let maxEndBar = 0.0; + + for (const track of sectionContainer.tracks) { + for (const item of track.items) { + const itemEndBar = item.start_bar + item.duration_bars; + if (itemEndBar > maxEndBar) { + maxEndBar = itemEndBar; + } + } + } + + // Enforce baseline grid quantization rounding (e.g. minimum 1 bar) + const computedLength = Math.max(1.0, Math.ceil(maxEndBar)); + sectionContainer.length_bars = computedLength; + + return computedLength; +} + +``` + +#### 5.3 Algorithm 3: Piano Roll Grid Mapping & Quantization Math + +##### Grid Coordinate Transformation Formulae + +Let: + +* $X_{\text{px}}$ = Pixel X-coordinate on Piano Roll Canvas. +* $Y_{\text{px}}$ = Pixel Y-coordinate on Piano Roll Canvas. +* $\text{Zoom}_x$ = Pixels per Beat. +* $\text{NoteHeight}$ = Height in pixels per pitch key row (e.g., 18px). +* $\text{Scroll}_x$ = Horizontal scroll offset in beats. +* $\text{Scroll}_y$ = Vertical scroll top note pitch (e.g., pitch 127 down to 0). + +Beat to Canvas Pixel Conversion: + +$$X_{\text{px}} = (\text{Beat} - \text{Scroll}_x) \times \text{Zoom}_x$$ + +$$\text{Beat} = \frac{X_{\text{px}}}{\text{Zoom}_x} + \text{Scroll}_x$$ + +Pitch to Canvas Pixel Conversion: + +$$Y_{\text{px}} = (127 - \text{Pitch} - \text{Scroll}_y) \times \text{NoteHeight}$$ + +$$\text{Pitch} = 127 - \left\lfloor \frac{Y_{\text{px}}}{\text{NoteHeight}} \right\rfloor - \text{Scroll}_y$$ + +##### Quantization (Snap To Grid) Math + +Let $Q$ be the snap unit in beats (e.g., $1/4 \text{ bar} = 1.0 \text{ beat}$, $1/16 \text{ note} = 0.25 \text{ beat}$). Given raw unquantized beat $B_{\text{raw}}$: + +$$B_{\text{quantized}} = \text{round}\left(\frac{B_{\text{raw}}}{Q}\right) \times Q$$ + +#### 5.4 Algorithm 4: Tab Close Dependency & Lifecycle Validation Algorithm + +This algorithm validates whether a tab close request can be fulfilled, enforcing the fixed Main Session constraint and preventing parent Section tab closures while child editor sub-tabs remain active. + +```javascript +function requestCloseTab(tabIdToClose, stateStore) { + const targetTab = stateStore.open_tabs.find(tab => tab.tab_id === tabIdToClose); + if (!targetTab) { + return { success: false, reason: "TAB_NOT_FOUND" }; + } + + // 1. Rule: Main Session cannot be closed + if (!targetTab.is_closeable || targetTab.type === 'MAIN_SESSION') { + return { success: false, reason: "CANNOT_CLOSE_MAIN_SESSION" }; + } + + // 2. Rule: Section Tab cannot be closed if child tabs are active + if (targetTab.type === 'SECTION_TAB') { + const activeChildTabs = stateStore.open_tabs.filter( + tab => tab.parent_tab_id === targetTab.tab_id + ); + + if (activeChildTabs.length > 0) { + return { + success: false, + reason: "SECTION_HAS_ACTIVE_CHILD_EDITORS", + activeChildTabs: activeChildTabs.map(t => ({ id: t.tab_id, title: t.title })) + }; + } + } + + // 3. Execution: Perform clean tab shutdown and update active context + const updatedTabs = stateStore.open_tabs.filter(tab => tab.tab_id !== tabIdToClose); + + // Fallback active tab selection if current active tab is being closed + let nextActiveTabId = stateStore.active_tab_id; + if (stateStore.active_tab_id === tabIdToClose) { + // Fallback to parent tab, or default to main session (index 0) + nextActiveTabId = targetTab.parent_tab_id || updatedTabs[0].tab_id; + } + + stateStore.open_tabs = updatedTabs; + stateStore.active_tab_id = nextActiveTabId; + + return { success: true, nextActiveTabId: nextActiveTabId }; +} + +``` + +#### 5.5 Algorithm 5: Sample-Accurate Lookahead MIDI & Audio Scheduler + +Web Audio API timing operates on a high-precision hardware audio clock (`audioContext.currentTime`). JavaScript timers (`setTimeout`/`setInterval`) lack frame accuracy. The Lookahead Scheduler combines JS interval ticks with Web Audio precision scheduling. + +```text + Lookahead Window (e.g. 100ms) +|-------------------------------------------| +| AudioContext Time: 10.0s | +| Schedule horizon: 10.1s | +| | +| [Event 1 @ 10.02s] -> Scheduled in WebAudio +| [Event 2 @ 10.08s] -> Scheduled in WebAudio +|___________________________________________| + +``` + +##### Scheduler Specification + +```javascript +class PrecisionAudioScheduler { + constructor(audioCtx, lookaheadMs = 25.0, scheduleAheadTimeSec = 0.1) { + this.audioCtx = audioCtx; + this.lookaheadMs = lookaheadMs; // Frequency of timer evaluation + this.scheduleAheadTime = scheduleAheadTimeSec; // How far ahead to queue WebAudio events + this.nextNoteBeat = 0.0; + this.currentBeat = 0.0; + this.bpm = 120.0; + this.timerId = null; + } + + beatToTime(beat) { + const secondsPerBeat = 60.0 / this.bpm; + return beat * secondsPerBeat; + } + + timeToBeat(timeSec) { + const secondsPerBeat = 60.0 / this.bpm; + return timeSec / secondsPerBeat; + } + + schedulerTick(activeSession) { + const currentTime = this.audioCtx.currentTime; + const horizonTime = currentTime + this.scheduleAheadTime; + + // Traverse session items and find notes falling within [currentTime, horizonTime] + const pendingEvents = activeSession.getEventsInTimeRange( + this.timeToBeat(currentTime), + this.timeToBeat(horizonTime) + ); + + for (const evt of pendingEvents) { + if (!evt.scheduled) { + const preciseAudioTime = currentTime + this.beatToTime(evt.targetBeat - this.currentBeat); + this.triggerWebAudioEvent(evt, preciseAudioTime); + evt.scheduled = true; + } + } + } + + triggerWebAudioEvent(evt, exactAudioTime) { + if (evt.type === 'MIDI_NOTE_ON') { + const synthNode = evt.trackSynthNode; + synthNode.noteOn(evt.note.pitch, evt.note.velocity, exactAudioTime); + synthNode.noteOff(evt.note.pitch, exactAudioTime + this.beatToTime(evt.note.duration_beats)); + } else if (evt.type === 'AUDIO_CLIP') { + const sourceNode = this.audioCtx.createBufferSource(); + sourceNode.buffer = evt.audioBuffer; + sourceNode.connect(evt.trackGainNode); + sourceNode.start(exactAudioTime, evt.offsetSec, evt.durationSec); + } + } + + start(session) { + this.timerId = setInterval(() => this.schedulerTick(session), this.lookaheadMs); + } + + stop() { + if (this.timerId) clearInterval(this.timerId); + } +} + +``` + +#### 5.6 Algorithm 6: Playhead UI Rendering Sync Loop + +UI Playhead rendering uses `requestAnimationFrame` and queries `audioContext.currentTime` directly to prevent visual jitter or lag. + +$$\text{Current Beat UI} = \frac{\text{audioCtx.currentTime} - \text{PlaybackStartTimeSec}}{\text{SecondsPerBeat}}$$ + +$$\text{Pixel Position X} = (\text{Current Beat UI} - \text{ViewportStartBeat}) \times \text{Zoom}_x$$ + +--- + +### 6. Backend Python Server Architecture & Offline Render Spec + +#### 6.1 Server Architecture Framework + +* **Framework:** FastAPI with Async WebSocket endpoints for real-time state synchronization. +* **DSP Engine:** `pedalboard` (Spotify's Python Audio Processing Library) and `numpy` for multi-track mixing, high-quality audio resampling, and plugin hosting. + +#### 6.2 Python Offline Stem Bouncing Engine Specification (`render_engine.py`) + +```python +import numpy as np +from pedalboard import Pedalboard, Gain, Reverb, Compressor +import soundfile as sf + +class PythonRenderEngine: + def __init__(self, sample_rate=44100): + self.sample_rate = sample_rate + + def bars_to_samples(self, bars: float, bpm: float, time_sig_num: int) -> int: + seconds_per_beat = 60.0 / bpm + seconds_per_bar = seconds_per_beat * time_sig_num + return int(bars * seconds_per_bar * self.sample_rate) + + def render_project(self, project_json: dict, output_filepath: str): + bpm = project_json["metadata"]["bpm"] + time_sig_num = project_json["metadata"]["time_signature_numerator"] + main_session = project_json["main_session"] + + # 1. Compute total project samples + total_bars = main_session.get("length_bars", 16.0) + total_samples = self.bars_to_samples(total_bars, bpm, time_sig_num) + + # Stereo Master Buffer + master_buffer = np.zeros((2, total_samples), dtype=np.float32) + + # 2. Iterate and process main tracks + for track in main_session["tracks"]: + track_type = track["type"] + track_buffer = np.zeros((2, total_samples), dtype=np.float32) + + for item in track["items"]: + start_sample = self.bars_to_samples(item["start_bar"], bpm, time_sig_num) + dur_samples = self.bars_to_samples(item["duration_bars"], bpm, time_sig_num) + offset_sample = self.bars_to_samples(item["clip_start_offset_bars"], bpm, time_sig_num) + + if item["type"] == "AUDIO_ITEM": + # Load audio source sample array + audio_data, sr = sf.read(item["source_data"]["audio_file_url"], dtype='float32') + audio_data = audio_data.T # Shape: (channels, samples) + + # Apply non-destructive trimming offset + sliced_audio = audio_data[:, offset_sample : offset_sample + dur_samples] + + # Accumulate into track buffer with bounds checks + end_sample = min(start_sample + sliced_audio.shape[1], total_samples) + actual_len = end_sample - start_sample + track_buffer[:, start_sample:end_sample] += sliced_audio[:, :actual_len] + + # Apply Track Gain and FX Chain via Pedalboard + board = Pedalboard([Gain(gain_db=track.get("volume_db", 0.0))]) + processed_track = board(track_buffer, sample_rate=self.sample_rate) + + # Mix down to Master + master_buffer += processed_track + + # 3. Write final output file + sf.write(output_filepath, master_buffer.T, self.sample_rate) + return output_filepath + +``` + +--- + +### 7. Execution Context & Sub-Tab Lifecycle Matrix + +| Context Tab Type | Scope Identifier | View Boundaries | Is Closeable | Close Dependency Conditions | Audio Routing Target | +| --- | --- | --- | --- | --- | --- | +| **MAIN SESSION** | Root | Full Master Timeline ($0 \to N$ Bars) | No | Pinned permanently; cannot be closed | WebAudio Hardware Destination | +| **SECTION TAB** | Section_ID | Dynamic Section Bounds ($0 \to L_{\text{section}}$) | Yes | Blocked if any child editor sub-tabs are open | Target Section Bus Gain Node | +| **PIANO ROLL** | MIDIItem_ID | Item Source Length Bounds ($0 \to N_{\text{buffer}}$) | Yes | Can close freely; notifies parent Section tab | Track Instrument Synth Engine | +| **SAMPLE EDITOR** | AudioItem_ID | Sample Buffer Waveform ($0 \to T_{\text{sample}}$) | Yes | Can close freely; notifies parent Section tab | Track Audio Node Router | + +--- + +### 8. Summary of Non-Destructive Slice & Tab Lifecycle Validation + +* **Tab Close Prevention Test:** +1. `MAIN SESSION` close request is rejected immediately (`CANNOT_CLOSE_MAIN_SESSION`). +2. `Section_01` tab has an active child editor tab (`Piano Roll: Bassline`). +3. Request to close `Section_01` tab returns `SECTION_HAS_ACTIVE_CHILD_EDITORS`. +4. User closes `Piano Roll: Bassline` tab first. +5. Subsequent close request for `Section_01` succeeds and cleans up UI context. + + +* **8-Bar Source with 2-Bar Visible Crop Test:** +1. Given `MIDIItem` length = 8 bars ($0 \dots 8$). +2. User drags left boundary to Bar 4 and right boundary to Bar 6. +3. `start_bar = 4.0` (Global Session Placement), `duration_bars = 2.0`, `clip_start_offset_bars = 4.0`. +4. Transport reaches global Bar 4.0 $\to$ scheduler evaluates internal bounds $[4.0, 6.0)$ and triggers only visible notes while preserving complete 8-bar non-destructive source. \ No newline at end of file diff --git a/md/30_MIDI_SESSION.md b/md/30_MIDI_SESSION.md deleted file mode 100644 index 74137e2..0000000 --- a/md/30_MIDI_SESSION.md +++ /dev/null @@ -1,361 +0,0 @@ -Here is the conversion of the document into a professional English Markdown format: - -# ARCHITECTURAL, TECHNICAL, AND ALGORITHMIC SPECIFICATION - -## Sub-Session System, Section Arrangement & Piano Roll Tab (Hybrid DAW) - -This document details the technical solution for building a Hierarchical DAW Engine. This architecture enables nesting Sub-Sessions (Sections) inside the Main Session, alongside a Sub-Tab Editor system (including Piano Roll and Audio Sample Editor) to precisely edit MIDI and Audio Items. - ---- - -### 0. Non-Breaking Modular Principles (Integration & Backward Compatibility) - -To guarantee that new features do not disrupt the DAW's existing core logic and codebase, the entire extension architecture is designed according to these principles: - -* **Extensibility & Encapsulation:** -* The current Session architecture serves directly as the **Project Root / Main Session**. -* `SectionItem`, `ItemMIDI`, and `ItemAudio` operate as **Polymorphic Item Types** inheriting from the existing base `Item` class/interface. Existing Item logic (e.g., drag-and-drop, timeline trimming) remains $100\%$ untouched. - - -* **Decoupled State Pipeline:** -* The logic governing the Playhead, Transport controls (Play/Pause/Stop), and the global Audio Context of the Main Session remains unmodified. -* **Nested Time Mapping** acts solely as an intermediate Transformation Layer when passing time coordinates down into Sub-Sessions. It does not overwrite or mutate the beat synchronization loop of the Main Timeline. - - -* **Plugin Style Architecture (Audio & MIDI Engine):** -* Synth Tracks, Audio Clip Processors, and Sub-Session Sub-Mix Buses plug into the existing AudioNode Graph as auxiliary nodes. They route directly back to the current Master Node without breaking pre-established Gain/Pan/FX pipelines. - - - ---- - -### 1. Hierarchical Data Model - -To support embedding Sessions within Sessions as well as isolated Clip/Sample-level editing, the data state model expands into an encapsulated Tree Graph structure. - -```text -Project Root -├── Main Session (Root Session - Current Session Structure) -│ ├── Track 01 (Audio Track) -│ │ └── ItemAudio: "Vocals.wav" ──► [Opens Audio Sample Editor Sub-Tab] -│ ├── Track 02 (MIDI Track + Synth Engine) -│ │ └── ItemMIDI: "Melody_Main" ──► [Opens Piano Roll Sub-Tab] -│ └── Track 03 (Section Track - New Track Type) -│ └── Item: Section_A (Referencing SubSession_01) -│ -├── Sub-Sessions Store (Auxiliary Memory Registry) -│ ├── SubSession_01 ("Verse 1") -│ │ ├── Computed Length: Dynamic Bars (Auto-calculated from longest Item) -│ │ ├── Track 1.1 (Audio Track) -│ │ │ └── ItemAudio: "Guitar_Riff.wav" ──► [Opens Audio Sample Editor Sub-Tab] -│ │ └── Track 1.2 (MIDI Track) -│ │ └── ItemMIDI: "Bassline" ─────────► [Opens Piano Roll Sub-Tab] -│ └── SubSession_02 ("Chorus") -│ -└── Active Editor Views / Sub-Tabs (Isolated Editing Contexts) - ├── Audio Sample Editor Sub-Tab (Edits Audio Clips from Main Session or Sub-Session) - └── Piano Roll Sub-Tab (Edits MIDI Items from Main Session or Sub-Session) - -``` - -#### Detailed Data Schemas (JSON Specs) - -**a. Schema: `NoteMIDI**` - -```typescript -interface NoteMIDI { - id: string; - pitch: number; // 0 - 127 (Midi Note Number, e.g., 60 = C4) - startTick: number; // Time coordinate based on Pulses Per Quarter note (PPQ, e.g., 960 PPQ) - durationTicks: number; - velocity: number; // 0 - 127 - selected?: boolean; -} - -``` - -**b. Schema: `ItemMIDI` (Belongs to MIDI Track - Inherits from Base Item)** - -```typescript -interface ItemMIDI { - id: string; - type: 'MIDI'; - name: string; - parentSessionId: string; // Target Session ID (Main or Sub-Session) - startBar: number; // Start position on the Timeline (Bar) - lengthBars: number; // Item duration in Bars - offsetTick: number; // Internal trim offset - notes: NoteMIDI[]; // Array tracking MIDI Notes -} - -``` - -**c. Schema: `ItemAudio` (Belongs to Audio Track - Inherits from Base Item)** - -```typescript -interface ItemAudio { - id: string; - type: 'AUDIO'; - name: string; - parentSessionId: string; // Target Session ID (Main or Sub-Session) - startBar: number; - lengthBars: number; - samplePath: string; // Audio file path or Buffer Key - sampleOffsetSec: number; // Playback start point offset (Trim In) - gain: number; // Clip Gain - pitchShiftSemi: number; // Pitch Shift (Semitones) -} - -``` - -**d. Schema: `SectionItem` (Represents a Sub-Session inside the Main Session)** - -```typescript -interface SectionItem { - id: string; - type: 'SECTION'; - subSessionId: string; // Reference ID pointing to SubSession inside Memory Store - name: string; - startBar: number; - lengthBars: number; // Defaults to SubSession.computedLengthBars unless trimmed/cropped - loop: boolean; // Enables repetition if lengthBars > SubSession.computedLengthBars -} - -``` - -**e. Schema: `Session` (Unified structure for both Main Session and Sub-Session)** - -```typescript -interface Session { - id: string; - name: string; - isMain: boolean; - timeSignature: [number, number]; // e.g., [4, 4] - bpm: number; - tracks: Track[]; - - // Dynamically calculated derived state; never assigned manually - get computedLengthBars(): number; -} - -``` - ---- - -### 2. Audio & Synth Engine Routing Architecture (Web Audio API) - -For MIDI tracks to output audio, each is bound to an Instrument/Synth Instance. When a Section is placed onto the Main Session, all audio generated by its child tracks is bussed directly into the existing Gain/Pan matrix. - -#### Audio Node Graph Diagram - -```text -[MIDI Items] ──(Triggers)──► [Synth Engine / Soundfont / WebAssembly VSTi] - │ -[Audio Items] ──(Buffer Source)──────────┤ - ▼ - [Track Gain / Pan Node] - │ - ▼ - [Sub-Session Sub-Mix Bus Node] - │ - ┌──────────────────────┴──────────────────────┐ - ▼ ▼ - [Main Session Audio Graph] [Solo / Mute Logic] - (Current Audio Processing Logic) - │ - ▼ - [Master Destination] - -``` - -**Instrument Engine Processing Logic for MIDI Tracks:** - -* **Virtual Instrument Binding:** Every MIDI Track instantiates a synthesis `AudioNode` (e.g., Web Audio API Soundfont Player, WebSynth JS, or WASM Synthesizer). -* **Dynamic Polyphony Engine:** As playback scans across MIDI Notes, the system triggers `noteOn(pitch, velocity, time)` and `noteOff(pitch, time)` events. These are scheduled ahead of time ($100\text{ms} - 200\text{ms}$ Lookahead) via the `AudioContext.currentTime` clock. - ---- - -### 3. Tab UI Management & Event Processing Flow (Tab Navigation Stack) - -The graphical interface expands on a Tab Manager & Navigation Stack model to handle isolated views (Views/Sub-tabs) for specific data entities. - -```text -[ Tabs Bar ] ── [ Main Session ] │ [ Sub-Session: Verse 1 ] │ [ Piano Roll: Bassline ] │ [ Sample Edit: Vocals.wav ] - -``` - -#### Interaction & Navigation Mechanics: - -* **Opening a Sub-Session Tab:** -* *Action:* User double-clicks a `SectionItem` on a Main Track. -* *Result:* -* Instantiates a new Tab using `ID = SubSession.id`. -* Maps the Timeline Viewport rendering context to the SubSession. -* Enables adding, editing, or deleting child tracks (Audio & MIDI) within the Sub-Session boundary. - - - - -* **Opening the Piano Roll Sub-Tab:** -* *Action:* User double-clicks an `ItemMIDI` inside the Main Session OR a Sub-Session. -* *Result:* -* Instantiates a Sub-tab labeled: `Piano Roll - [Item Name]`. -* Caches context references: `{ itemId, parentSessionId }`. -* Passes the `ItemMIDI.notes` array directly into the Canvas/Piano Roll Grid. -* Any add/edit/delete actions executed on notes inside the Piano Roll instantly update the native `ItemMIDI` in the target Session via Mutable/Immutable References. - - - - -* **Opening the Audio Sample Editor Sub-Tab (Session Edit Audio Sample):** -* *Action:* User double-clicks OR right-clicks and selects "Edit" on an `ItemAudio` inside the Main Session or a Sub-Session. -* *Result:* -* Instantiates a Sub-tab labeled: `Audio Editor - [Clip Name]`. -* Loads the high-resolution Waveform of the target `ItemAudio` onto the sample editing Viewport. -* Provides access to tools: Trim start/end, Normalized Peak, Pitch Shift, Reverse, Fade In/Out, or DSP slicing. -* When clicking *Save / Apply Changes*: The system updates the `ItemAudio` attributes (or dispatches a DSP processing request to the Python Server for heavy tasks) and forces a visual refresh of the Clip on the Main Session / Sub-Session timeline. - - - - - -#### Data Persistence & Dynamic Sub-Session Length Updates: - -* Because JavaScript handles array/object data passing by **Reference**, modifications made to Notes in the Piano Roll Tab or Clips in the Audio Editor directly update the origin State of the corresponding Session. -* Any add/remove/move/stretch operation targeting an Item inside a Sub-session will immediately trigger the **Dynamic Length Recalculation** algorithm to update the temporal boundary of the Sub-Session. - ---- - -### 4. Core Algorithms - -#### Algorithm 1: Dynamic Sub-Session Length Calculation - -Sub-sessions do not enforce rigid length constraints. Instead, they dynamically map their duration ($L_{\text{bars}}$) to match the furthest end-point of all encapsulated Items. - -**Formula:** -Given a Sub-Session containing a list of $T$ tracks, where each track $t$ holds a list of $I_t$ items (Audio, MIDI, etc.): - - -$$\text{ItemEndBar}(item) = item.\text{startBar} + item.\text{lengthBars}$$ - -$$L_{\text{bars}} = \max_{t \in T} \left( \max_{i \in I_t} (\text{ItemEndBar}(i)) \right)$$ - -*If the Sub-session is entirely empty (contains no Items), $L_{\text{bars}}$ defaults to $1$ Bar (or the default duration of a single grid bar).* - -```javascript -function calculateSubSessionLength(subSession) { - let maxEndBar = 1; // Minimum duration fallback for empty sub-sessions - - for (const track of subSession.tracks) { - for (const item of track.items) { - const itemEndBar = item.startBar + item.lengthBars; - if (itemEndBar > maxEndBar) { - maxEndBar = itemEndBar; - } - } - } - - return maxEndBar; -} - -``` - -#### Algorithm 2: Nested Time Mapping - -When the Main Session Playhead tracks time $T_{\text{main}}$ (seconds), the engine must calculate the relative time coordinate $T_{\text{sub}}$ inside the active Sub-Session. - -**Formula:** -Assume: - -* $S_{\text{bar}}$: The starting Bar of the Section Item on the Main Timeline. -* $L_{\text{bars}}$: The dynamically evaluated length of the root Sub-Session ($L_{\text{bars}} = \text{calculateSubSessionLength}(\text{SubSession})$). -* $BPM$: Beats Per Minute. -* $TimeSig$: Beats per Bar (e.g., 4 beats). - -$$\text{SecondsPerBar} = \frac{60}{\text{BPM}} \times \text{TimeSig}$$ - -$$\text{OffsetSeconds} = (T_{\text{main}} - (S_{\text{bar}} - 1) \times \text{SecondsPerBar})$$ - -If `SectionItem.loop = true`: - - -$$T_{\text{sub}} = \text{OffsetSeconds} \pmod{L_{\text{bars}} \times \text{SecondsPerBar}}$$ - -If `SectionItem.loop = false`: - - -$$T_{\text{sub}} = \begin{cases} \text{OffsetSeconds} & \text{if } 0 \le \text{OffsetSeconds} \le (L_{\text{bars}} \times \text{SecondsPerBar}) \\ \text{undefined} & \text{if out of bounds} \end{cases}$$ - -#### Algorithm 3: Lookahead MIDI Scheduler - -JavaScript's `setInterval` function lacks the temporal precision required for audio playback. We employ the **Web Audio Lookahead Scheduler** algorithm combined with Ticks $\rightarrow$ Seconds translation. - -```javascript -const PPQ = 960; // 960 Pulses Per Quarter note (Standard MIDI resolution) -let nextNoteIndex = 0; -const scheduleAheadTime = 0.2; // 200ms Lookahead buffer -const lookaheadMs = 25; // Polling interval interval block (25ms) - -function ticksToSeconds(ticks, bpm) { - const secondsPerQuarterNote = 60.0 / bpm; - return (ticks / PPQ) * secondsPerQuarterNote; -} - -function scheduler(midiItem, audioCtx, currentPlayheadTime) { - // Extract notes mapped within [currentPlayheadTime, currentPlayheadTime + scheduleAheadTime] - while (nextNoteIndex < midiItem.notes.length) { - const note = midiItem.notes[nextNoteIndex]; - const noteStartTimeSec = ticksToSeconds(note.startTick, currentBpm); - - if (noteStartTimeSec >= currentPlayheadTime + scheduleAheadTime) { - break; // Note start bounds exceed the active Lookahead window - } - - if (noteStartTimeSec >= currentPlayheadTime) { - // Calculate absolute scheduling time against the AudioContext Clock - const audioCtxStartTime = audioCtx.currentTime + (noteStartTimeSec - currentPlayheadTime); - const durationSec = ticksToSeconds(note.durationTicks, currentBpm); - - // Fire the VSTi/Synth Engine - trackSynthEngine.playNote(note.pitch, note.velocity, audioCtxStartTime, durationSec); - } - nextNoteIndex++; - } -} - -``` - -#### Algorithm 4: Grid Snapping & Quantization (Piano Roll) - -When adding or dragging a MIDI Note in the Piano Roll Tab, the $X$ coordinate of the mouse cursor must snap to the nearest rhythmic grid boundary (1/4, 1/8, 1/16, 1/32 Note). - -```javascript -function snapTickToGrid(rawTick, gridFraction, ppq) { - // gridFraction: 0.25 (1/4 note), 0.125 (1/8 note), 0.0625 (1/16 note) - const ticksPerGridStep = ppq * (gridFraction * 4); - - // Snap rounding formula targeting the nearest grid boundary - const snappedTick = Math.round(rawTick / ticksPerGridStep) * ticksPerGridStep; - return Math.max(0, snappedTick); -} - -``` - ---- - -### 5. Performance Optimization - -* **Virtual Rendering for Piano Roll, Audio Sample Editor & Main Session:** -* Never render the entire array of MIDI Notes or total Audio Waveforms simultaneously into the HTML DOM. -* Mandatory use of HTML5 Canvas 2D / WebGL paired with **Virtual Viewport Rendering** (only drawing Notes/Samples situated within the active Viewport Rect boundary). - - -* **Audio Bouncing / Freezing (For Heavy Sections):** -* If a Sub-Session houses too many Tracks and VSTi plugins, causing CPU bottlenecks during Main Session playback: -* Enable the **"Freeze Section"** action: The Python backend processes the request, rendering that entire Sub-Session block into a single temporary Audio WAV file (Bounce to Disk). -* The Main Session then only processes one discrete Audio file instead of simultaneously calculating dozens of child tracks. - - -* **Immutable State & Undo/Redo Engine:** -* Project State management is handled via the Redux/Zustand pattern model. -* Every add/edit/delete operation applied to Notes on the Piano Roll or edits made to Audio Clips generates an Action that pushes to the `UndoStack`, supporting seamless `Ctrl + Z` shortcuts across every Sub-tab context. \ No newline at end of file diff --git a/md/31_MIDIKEY.md b/md/31_MIDIKEY.md new file mode 100644 index 0000000..da8061c --- /dev/null +++ b/md/31_MIDIKEY.md @@ -0,0 +1,426 @@ +# TECHNICAL SPECIFICATION: CLIENT-SIDE REAL-TIME RECORDING ENGINE + +## Browser-Based Microphone & Hardware MIDI Keyboard Recording Module + +--- + +## 1. System Overview + +The Client-side recording module enables the DAW to capture live audio signals directly from Microphone/Line-in interfaces (via the Web MediaDevices API) and keypress events from Hardware MIDI Keyboards/Controllers (via the Web MIDI API) in real time. The module operates with low latency and includes hardware latency compensation. + +```text ++-----------------------------------------------------------------------------------+ +| CLIENT BROWSER | +| | +| +-------------------------+ +-----------------------------+ | +| | Hardware MIDI Keyboard | | Live Microphone / Line-In | | +| +------------+------------+ +--------------+--------------+ | +| | | | +| v (Web MIDI API) v (getUserMedia) | +| +------------+------------+ +--------------+--------------+ | +| | Web MIDI Input Handler | | MediaStreamAudioSourceNode | | +| +------------+------------+ +--------------+--------------+ | +| | | | +| +------------------+ | | +| | | v | +| v v +--------------+--------------+ | +| +------------+-----+ +---------+-----------+ | Track Input Gain Node | | +| | Event Clock / | | WebAudio Virtual | +--------------+--------------+ | +| | Latency Engine | | Synth Engine | | | +| +------------+-----+ +---------+-----------+ +--------+--------+ | +| | | | | | +| v v v v | +| +------------+-----+ (Live Sound) +-------+-------+ +-------+------+ | +| | Recorded MIDI | | AudioWorklet | | Monitoring | | +| | Buffer | | Ring-Buffer | | Switch | | +| +------------+-----+ | Recorder | +-------+------+ | +| | +-------+-------+ | | +| v | v | +| [ Timeline MIDI ] v [ Master Mix ] | +| [ Item Creation ] +-------+-------+ | +| | Float32 PCM | | +| | Audio Buffer | | +| +-------+-------+ | +| | | +| v | +| [ Timeline Audio] | +| [ Item Creation ] | ++-----------------------------------------------------------------------------------+ + +``` + +--- + +## 2. Hardware I/O & API Contracts + +### 2.1 MediaDevices (Microphone Capture) + +**Permission Request:** Uses `navigator.mediaDevices.getUserMedia` configured to disable automatic browser processing DSP algorithms to capture pure, unprocessed audio signals: + +```javascript +const audioConstraints = { + audio: { + deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined, + echoCancellation: false, // Disables echo cancellation to prevent instrument sound distortion + noiseSuppression: false, // Disables automatic noise suppression to preserve full frequency range + autoGainControl: false, // Disables Automatic Gain Control (AGC) + latency: 0 // Requests minimal latency from OS audio driver + } +}; + +``` + +### 2.2 Web MIDI API Integration + +**Device Enumeration & Listener Assignment:** + +* Uses `navigator.requestMIDIAccess({ sysex: false })` to scan for USB-connected keyboard devices. +* **Timestamp Precision:** Obtains event timestamps from `MIDIMessageEvent.timeStamp` (as a `DOMHighResTimeStamp` in microseconds) and synchronizes them with `AudioContext.currentTime`. + +--- + +## 3. Recording Lifecycle & State Machine + +```text + [IDLE] ───► (User Arms Track) ───► [ARMED] ───► (Press Rec + Play) ───► [COUNT-IN / PRE-ROLL] + | + [STOP & COMMIT] ◄─── (Press Stop) ◄─── [RECORDING IN PROGRESS] ◄───────────────+ + +``` + +* **Arming Phase (Record Enable):** +* The user selects an input source and activates the Arm (R) button on the target track. +* Initializes the input level meter (VU Meter Canvas) to display input volume levels in real time. + + +* **Pre-Roll / Count-In Phase:** +* Transport triggers the metronome count-in (e.g., 1 Bar = 4 beats). The metronome plays click sounds based on project BPM. +* The engine does not write data to the Timeline yet, but begins reading the input buffer to prepare memory buffers. + + +* **Recording Phase:** +* Once the transport passes the Start Bar boundary, incoming MIDI key events or PCM Float32 audio samples are written into the active recording buffer memory. +* Canvas UI displays real-time visual feedback, rendering waveforms or MIDI note blocks dynamically. + + +* **Stop & Commit Phase:** +* Pressing Stop halts the recording process. +* Converts temporary memory buffers into a structured `MIDIItem` or `AudioItem`. +* Inserts the new Item onto the target track within the Main Session or Section tab. + + + +--- + +## 4. Data Structures + +### 4.1 Live MIDI Event Buffer Element Schema + +```json +{ + "type": "object", + "properties": { + "pitch": { "type": "integer", "minimum": 0, "maximum": 127 }, + "start_beat": { "type": "number", "description": "Start position in beats on the timeline" }, + "duration_beats": { "type": "number", "description": "Keypress duration in beats" }, + "velocity": { "type": "number", "minimum": 0.0, "maximum": 1.0 }, + "channel": { "type": "integer", "default": 0 } + } +} + +``` + +### 4.2 Recording Track Input Configuration State + +```json +{ + "track_id": "track_midi_01", + "is_armed": true, + "monitoring_enabled": true, + "input_source": { + "device_type": "MIDI_KEYBOARD", + "device_id": "midi_input_usb_keyboard_0", + "channel": 1 + }, + "input_gain_db": 0.0, + "latency_offset_ms": 12.5 +} + +``` + +--- + +## 5. Core Algorithms & Latency Compensation + +### 5.1 Algorithm 1: Hardware Latency Compensation Formula + +When recording, the physical moment a key is pressed or sound enters the microphone is inherently delayed relative to speaker output due to input buffers ($L_{\text{input}}$) and output buffers ($L_{\text{output}}$). + +#### Mathematical Formulation + +Let: + +* $T_{\text{audio\_ctx}}$ = Current timestamp in seconds on the `AudioContext` clock (`audioCtx.currentTime`). +* $T_{\text{rec\_start}}$ = Recording start timestamp in seconds. +* $\text{BPM}$ = Song tempo (Beats Per Minute). +* $\text{TS}_{\text{num}}$ = Time Signature Numerator (beats per bar). +* $\text{Bar}_{\text{start}}$ = Target timeline start bar for recording. +* $L_{\text{comp}}$ = Total hardware latency offset ($L_{\text{input}} + L_{\text{output}} + L_{\text{user\_offset}}$) in seconds. + +**Actual Elapsed Audio Time ($T_{\text{elapsed}}$):** + +$$T_{\text{elapsed}} = \max\left(0, T_{\text{audio\_ctx}} - T_{\text{rec\_start}} - L_{\text{comp}}\right)$$ + +**Audio Time to Beat Conversion ($\text{Beat}_{\text{current}}$):** + +$$\text{SecondsPerBeat} = \frac{60.0}{\text{BPM}}$$ + +$$\text{Beat}_{\text{current}} = \frac{T_{\text{elapsed}}}{\text{SecondsPerBeat}} + \left(\text{Bar}_{\text{start}} \times \text{TS}_{\text{num}}\right)$$ + +**Timeline Placement Mapping:** + +$$\text{StartBeat}_{\text{item}} = \text{Beat}_{\text{current}}$$ + +--- + +### 5.2 Algorithm 2: AudioWorklet PCM Ring-Buffer Processor + +To prevent audio glitches or missing PCM frames when the browser's main thread is processing heavy UI renders, microphone recording runs inside an `AudioWorkletProcessor`: + +```javascript +// public/processors/pcm-recorder-processor.js +class PCMRecorderProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.bufferSize = 4096; + this.buffer = new Float32Array(this.bufferSize); + this.bufferIndex = 0; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + if (input && input.length > 0) { + const inputChannel = input[0]; // Mono Channel 0 + + for (let i = 0; i < inputChannel.length; i++) { + this.buffer[this.bufferIndex++] = inputChannel[i]; + + // When Ring-Buffer fills, send Float32Array to Main Thread + if (this.bufferIndex >= this.bufferSize) { + this.port.postMessage({ + type: 'PCM_DATA', + buffer: this.buffer.slice(0, this.bufferSize) + }); + this.bufferIndex = 0; + } + } + } + return true; // Keep worklet active + } +} + +registerProcessor('pcm-recorder-processor', PCMRecorderProcessor); + +``` + +--- + +### 5.3 Algorithm 3: Client MIDIRecorder Class Implementation + +```javascript +class ClientMIDIRecorder { + constructor(audioContext, bpm = 120, timeSigNumerator = 4) { + this.audioCtx = audioContext; + this.bpm = bpm; + this.timeSigNum = timeSigNumerator; + this.isRecording = false; + + this.activeNotes = new Map(); // Store pitch -> { noteId, startBeat, velocity } + this.recordedNotes = []; + this.recStartAudioTime = 0.0; + this.recStartBar = 0.0; + + // Compute round-trip browser latency + this.latencyCompSec = (this.audioCtx.baseLatency || 0) + (this.audioCtx.outputLatency || 0); + } + + start(startBar = 0.0) { + this.isRecording = true; + this.recordedNotes = []; + this.activeNotes.clear(); + this.recStartBar = startBar; + this.recStartAudioTime = this.audioCtx.currentTime; + + this.bindMIDIInputs(); + } + + bindMIDIInputs() { + if (navigator.requestMIDIAccess) { + navigator.requestMIDIAccess().then(midiAccess => { + for (let input of midiAccess.inputs.values()) { + input.onmidimessage = (event) => this.handleMIDIMessage(event); + } + }); + } + } + + handleMIDIMessage(event) { + if (!this.isRecording) return; + + const [status, pitch, velocity] = event.data; + const command = status >> 4; + + // Apply latency compensation formula + const currentTimeSec = Math.max(0, this.audioCtx.currentTime - this.recStartAudioTime - this.latencyCompSec); + const secondsPerBeat = 60.0 / this.bpm; + const currentBeat = (currentTimeSec / secondsPerBeat) + (this.recStartBar * this.timeSigNum); + + // Command 0x9: Note On + if (command === 0x9 && velocity > 0) { + const noteId = `rec_${Date.now()}_${pitch}`; + this.activeNotes.set(pitch, { + id: noteId, + pitch: pitch, + start_beat: currentBeat, + velocity: velocity / 127.0 + }); + } + // Command 0x8: Note Off (or Note On with velocity = 0) + else if (command === 0x8 || (command === 0x9 && velocity === 0)) { + if (this.activeNotes.has(pitch)) { + const note = this.activeNotes.get(pitch); + const durationBeats = Math.max(0.125, currentBeat - note.start_beat); // Min 1/32 note + + this.recordedNotes.push({ + id: note.id, + pitch: note.pitch, + start_beat: note.start_beat, + duration_beats: durationBeats, + velocity: note.velocity, + pan: 0.0 + }); + + this.activeNotes.delete(pitch); + } + } + } + + stop() { + this.isRecording = false; + + // Flush remaining active keypresses when stop is triggered + const currentTimeSec = Math.max(0, this.audioCtx.currentTime - this.recStartAudioTime - this.latencyCompSec); + const currentBeat = (currentTimeSec / (60.0 / this.bpm)) + (this.recStartBar * this.timeSigNum); + + for (let [pitch, note] of this.activeNotes.entries()) { + this.recordedNotes.push({ + id: note.id, + pitch: note.pitch, + start_beat: note.start_beat, + duration_beats: Math.max(0.25, currentBeat - note.start_beat), + velocity: note.velocity, + pan: 0.0 + }); + } + this.activeNotes.clear(); + return this.recordedNotes; + } +} + +``` + +--- + +### 5.4 Algorithm 4: Client AudioRecorder & AudioBuffer Splicing Class Implementation + +```javascript +class ClientAudioRecorder { + constructor(audioContext) { + this.audioCtx = audioContext; + this.mediaStream = null; + this.sourceNode = null; + this.workletNode = null; + this.pcmChunks = []; + this.isRecording = false; + } + + async initializeInput(deviceId = null) { + const constraints = { + audio: { + deviceId: deviceId ? { exact: deviceId } : undefined, + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false + } + }; + this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints); + this.sourceNode = this.audioCtx.createMediaStreamSource(this.mediaStream); + } + + async start(destinationTrackGainNode, enableMonitoring = true) { + this.pcmChunks = []; + this.isRecording = true; + + // Load Worklet Processor Module + await this.audioCtx.audioWorklet.addModule('/processors/pcm-recorder-processor.js'); + this.workletNode = new AudioWorkletNode(this.audioCtx, 'pcm-recorder-processor'); + + // Receive PCM data streams from AudioWorklet + this.workletNode.port.onmessage = (event) => { + if (this.isRecording && event.data.type === 'PCM_DATA') { + this.pcmChunks.push(new Float32Array(event.data.buffer)); + } + }; + + // Route Audio Nodes + this.sourceNode.connect(this.workletNode); + + // Enable Live Input Monitoring if requested + if (enableMonitoring) { + this.sourceNode.connect(destinationTrackGainNode); + } + } + + async stop() { + this.isRecording = false; + + if (this.sourceNode && this.workletNode) { + this.sourceNode.disconnect(this.workletNode); + } + + // Concatenate PCM Float32Array chunks into a single AudioBuffer + const totalSamples = this.pcmChunks.reduce((sum, chunk) => sum + chunk.length, 0); + if (totalSamples === 0) return null; + + const audioBuffer = this.audioCtx.createBuffer(1, totalSamples, this.audioCtx.sampleRate); + const channelData = audioBuffer.getChannelData(0); + + let offset = 0; + for (const chunk of this.pcmChunks) { + channelData.set(chunk, offset); + offset += chunk.length; + } + + return audioBuffer; // Return compiled AudioBuffer for timeline insertion + } +} + +``` + +--- + +## 6. UI Components & User Interactions + +* **Track Header Arming Controls:** +* **[R] Button (Arm Track):** Highlights red when armed for recording on the target track. +* **[I] Button (Input Monitor):** Toggles live monitoring for incoming Microphone or Synth audio during performance. +* **Input Selector Dropdown:** Allows selection of available Microphone devices or USB Hardware MIDI Keyboards. + + +* **Real-time VU Meter Component:** +* Displays input signal gain level from $-60\text{ dB}$ to $0\text{ dB}$. Displays red clipping indicators when signal levels exceed $0\text{ dBFS}$. + + +* **Live Waveform & MIDI Preview Rendering:** +* **Microphone Recording:** The canvas UI renders incoming waveform signals progressing along the Playhead position in real time. +* **MIDI Performance:** Rectangular note blocks (green/orange) appear at note-on trigger events and extend until key release (note-off). \ No newline at end of file diff --git a/md/32_SF_VSTi.md b/md/32_SF_VSTi.md new file mode 100644 index 0000000..16d836b --- /dev/null +++ b/md/32_SF_VSTi.md @@ -0,0 +1,237 @@ +Here is the clean, nicely formatted Markdown version of the technical specification document: + +# TECHNICAL INSTALLATION & INTEGRATION GUIDE FOR SOUNDFONT / VSTI IN DAW + +This document provides a detailed technical architecture model for integrating SoundFonts, WebAssembly Plugins (Client), and Native VSTi/AU (Server). It clearly delineates components pre-installed by the Developer (Coder) versus those open for User uploads and additions. + +--- + +## 1. Architectural Distribution Overview (Developer vs. User) + +| Plugin / Asset Category | Processing Location | Installed By | Storage & Management Method | Security & Safety Profile | +| --- | --- | --- | --- | --- | +| **Default SoundFont (`.sf2`)** | Client (Wasm) | Coder | Static Assets hosted on Web Server / CDN | Extremely High | +| **User Custom SoundFont (`.sf2`)** | Client (Wasm) | User | Browser `IndexedDB` or User Cloud Storage | Extremely High (Runs inside Wasm Sandbox) | +| **WebAssembly Synths (WAMs)** | Client (JS/Wasm) | Coder | Bundled within Frontend Source Code | Extremely High | +| **Core Server VSTi (Vital, Surge...)** | Server (Python) | Coder | System Directory inside Docker/Linux Container | High (Controlled binary footprint) | +| **User Custom VST3 / Preset** | Server (Python) | User (Restricted) | Stores `.vst3` files or `.fxp`/`.json` on Container | High Security Risk (Requires Sandboxing) | + +--- + +## 2. Client-Side Integration Tech (Browser / WebAssembly) + +The Client-Side handles zero-latency real-time composition and audio previews. + +### 2.1 Coder Pre-bundled Assets + +* **Static SoundFont Hosting:** +* The developer places standard `.sf2` files (such as `GeneralUser_GS.sf2`) into the `public/soundfonts/` directory or hosts them via CDN. +* Upon application startup, default SoundFonts are queried via REST API: +```http +GET /api/v1/assets/default-soundfonts + +``` + + +```json +[ + { "id": "sf_generaluser", "name": "GeneralUser GS v1.471", "size_mb": 31.2, "url": "/soundfonts/GeneralUser.sf2" }, + { "id": "sf_sso", "name": "Sonatina Symphonic Orchestra", "size_mb": 95.0, "url": "/soundfonts/SSO.sf2" } +] + +``` + + + + +* **FluidSynth WebAssembly Engine Integration:** +* Compiles FluidSynth C/C++ code into WebAssembly (`fluidsynth.wasm` + `fluidsynth.js`) using Emscripten. +* Alternatively, leverages open JavaScript wrappers such as `@soundfont/player` or `SpessaSynth`. + + + +### 2.2 Allowing User Custom SoundFont (`.sf2`) Uploads + +Delivers a flexible user experience without overloading server storage: + +* **Upload Mechanism & Local Cache (`IndexedDB`):** +* Users drag and drop `.sf2` files directly into the DAW interface. +* JavaScript reads the file as an `ArrayBuffer` via the `FileReader` API. +* The file persists directly within the browser's local `IndexedDB` cache for immediate reuse across sessions without re-uploading to the server. + + +* **Dynamic Injection into WebAssembly Memory:** +```javascript +// Client-side JavaScript snippet +async function loadUserSoundFont(fileBuffer) { + const uint8Array = new Uint8Array(fileBuffer); + // Write buffer straight into Emscripten FluidSynth Virtual File System (MEMFS) + Module.FS.writeFile('/user_font.sf2', uint8Array); + + // Call Wasm C-function to load bank + const sfont_id = Module._fluid_synth_sfload(synthInstance, '/user_font.sf2', 1); + console.log(`User SoundFont loaded successfully with ID: ${sfont_id}`); +} + +``` + + + +--- + +## 3. Server-Side Integration Tech (Python Backend Engine) + +The Server-Side executes high-resolution offline WAV rendering when an operator triggers the Export / Bounce workflow. + +### 3.1 Server Environment Installed by Coder + +The developer configures the Server environment (or Docker Container) with pre-installed Native C++ libraries and Python utilities. + +1. **Server Base `Dockerfile` Configuration:** +```dockerfile +FROM python:3.10-slim + +# Install Linux audio libraries +RUN apt-get update && apt-get install -y \ + fluidsynth \ + libfluidsynth-dev \ + libasound2-dev \ + libjack-jackd2-dev \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Initialize directories for Native VST3 and system SoundFonts +RUN mkdir -p /opt/daw_engine/vst3 \ + && mkdir -p /opt/daw_engine/soundfonts + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +``` + + +2. **Pre-installing Native VST3 Plugins:** +Places 64-bit Linux `.vst3` binary builds of open-source synths inside `/opt/daw_engine/vst3/`: +* `/opt/daw_engine/vst3/Vital.vst3` +* `/opt/daw_engine/vst3/Surge XT.vst3` +* `/opt/daw_engine/vst3/Dexed.vst3` + + +3. **Python Backend Integration via Spotify `pedalboard`:** +```python +# render_engine/vst_loader.py +import os +from pedalboard import VST3Plugin, Pedalboard + +class PluginManager: + def __init__(self, vst_dir="/opt/daw_engine/vst3"): + self.vst_dir = vst_dir + self.available_plugins = self._scan_plugins() + + def _scan_plugins(self): + plugins = {} + for root, dirs, files in os.walk(self.vst_dir): + for file in files: + if file.endswith(".vst3") or file.endswith(".so"): + plugin_path = os.path.join(root, file) + plugin_name = os.path.splitext(file)[0] + plugins[plugin_name] = plugin_path + return plugins + + def load_vst(self, plugin_name: str, preset_data: dict = None) -> VST3Plugin: + if plugin_name not in self.available_plugins: + raise FileNotFoundError(f"VST3 Plugin '{plugin_name}' not found on server.") + + path = self.available_plugins[plugin_name] + vst_instance = VST3Plugin(path) + + # Inject parameters if provided + if preset_data: + for param_name, param_value in preset_data.items(): + setattr(vst_instance, param_name, param_value) + + return vst_instance + +``` + + + +### 3.2 Handling User Custom Plugins / Presets + +#### Option 1: User Presets / Patches Uploads (**RECOMMENDED - Safe**) + +* **Implementation:** The backend locks native VST3 installations to common open engines (Vital, Dexed, Surge XT). Users upload lightweight preset patches like `.vitalbank`, `.syx` (DX7 patches), `.fxp`, or JSON parameter states. +* **Workflow:** +1. User selects the Vital Synth on the Client UI. +2. User clicks "Import Preset" $\rightarrow$ Uploads a `.vital` file or JSON parameter bundle. +3. Server parses JSON parameters and injects them directly into the VST3 instance via `pedalboard` during render execution. + + +* **Benefits:** Absolutely safe, minimal footprint, zero security vulnerabilities to the host infrastructure. + +#### Option 2: User Native Binary VST3 Uploads (**HIGH RISK - Requires Isolation**) + +* **Risk:** A `.vst3` file contains executable machine code (`.so` Shared Object on Linux). Accepting arbitrary uploads grants 100% vector exposure to Remote Code Execution (RCE) attacks. +* **Technical Mitigation (If Mandatory):** +* **Sandboxing Isolation:** Every user Export/Render request runs inside an isolated, short-lived container (Ephemeral Docker / Firejail / gVisor) stripped of `root` privileges and completely isolated from external internet interfaces. +* **Time-To-Live (TTL):** User `.vst3` binaries persist inside temporary directories `/tmp/user_sessions/{user_id}/` and purge automatically upon render job completion. + + + +--- + +## 4. API Specification for SoundFonts & Plugins + +### 4.1 OpenAPI Endpoint Spec for Frontend + +```yaml +/api/v1/plugins/available: + get: + summary: Query available VSTi engines and SoundFont resources on the Server + responses: + 200: + content: + application/json: + example: + vst_instruments: + - id: "vst_vital" + name: "Vital Wavetable Synth" + type: "VST3" + has_native_support: true + - id: "vst_dexed" + name: "Dexed FM Synth" + type: "VST3" + has_native_support: true + soundfonts: + - id: "sf_generaluser" + name: "GeneralUser GS" + file: "GeneralUser.sf2" + +/api/v1/projects/render: + post: + summary: Trigger offline DAW Project rendering to WAV on the Server + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectSchema' + responses: + 200: + description: Returns the URL pointing to the rendered WAV file + +``` + +--- + +## 5. Development Team Best Practices Summary + +* **SoundFont (`.sf2`):** +* **For Users:** Encourage unrestricted local uploads on the Client (Browser). Store assets in `IndexedDB` to ensure optimal real-time performance without straining server resources. +* **For Developers:** Supply 1–2 default General MIDI (GM) SoundFont banks (`GeneralUser_GS.sf2`) bundled on both Client and Server. + + +* **VSTi Instruments:** +* **For Developers:** Pre-install top open-source Linux-native synths on the Server (Vital, Surge XT, Dexed, OB-Xd). +* **For Users:** Do **not** allow direct `.vst3` binary uploads to the production server. Instead, permit users to upload Presets / Patches / JSON parameters for the supported synth models. This guarantees 100% security while saving storage and network bandwidth. \ No newline at end of file diff --git a/md/31_TAB_CLONE.md b/md/TAB_CLONE_template.md similarity index 100% rename from md/31_TAB_CLONE.md rename to md/TAB_CLONE_template.md diff --git a/requirements.txt b/requirements.txt index 1f19f83..0ab8d40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ scipy>=1.10.0 soundfile>=0.12.1 jinja2>=3.1.2 httpx>=0.24.0 +jsonschema>=4.18.0 +pedalboard>=0.8.0 diff --git a/tests/test_render_engine.py b/tests/test_render_engine.py new file mode 100644 index 0000000..ec713eb --- /dev/null +++ b/tests/test_render_engine.py @@ -0,0 +1,20 @@ +import os +import json +import pytest +from app.core.render_engine import PythonRenderEngine + +def test_render_engine_init(): + engine = PythonRenderEngine() + assert engine.sample_rate == 44100 + +def test_bars_to_samples(): + engine = PythonRenderEngine() + samples = engine.bars_to_samples(4.0, 120.0, 4) + assert samples == 352800 + +def test_render_session_container_empty(): + engine = PythonRenderEngine() + session = {"tracks": []} + buf = engine.render_session_container(session, {}, 120.0, 4, 1000) + assert buf.shape == (2, 1000) + assert (buf == 0.0).all()