fix: refactor

This commit is contained in:
2026-07-20 10:39:07 +07:00
parent 3c77e98956
commit c8ebdb50b0
21 changed files with 2862 additions and 110 deletions
+222
View File
@@ -0,0 +1,222 @@
# Kế hoạch phát triển SonicForge Studio
> Nguyên tắc chung: **Giữ nguyên UI đã thiết kế**, chỉ bổ sung/bổ khuyết các thành phần còn thiếu.
> Mọi thay đổi phải tương thích với code hiện tại (backend FastAPI + frontend React/Babel trong `index.html`).
---
## 0. Khảo sát hiện trạng (đã phân tích)
| Hạng mục | Trạng thái | Ghi chú |
|---|---|---|
| Backend Auth (login/register/change-password/profile) | ✅ Sẵn sàng | `app/api/v1/auth.py` |
| Mock password admin | ✅ `seed_admin()` | Mật khẩu mặc định `admin123`, `must_change_password=1` |
| Quota / System Manager API | ✅ Sẵn sàng | `app/api/v1/admin.py`, `app/api/v1/projects.py` |
| Phân tích Stereo/Mono | ✅ Sẵn sàng | `audioEngine.analyzeAudioBufferChannels()` |
| Auth UI (AuthModal/ProfileModal/SystemManagerModal) | ✅ Sẵn sàng | `app/static/js/components/*` |
| Temp project (local + cloud) | ✅ Sẵn sàng | `storage.scheduleTempAutoSave()`, API `/projects/temp`, `/projects/cloud` |
| Export/Import `.sfs` | ⚠️ Có nhưng thiếu double-click mở lại | `storage.exportProjectToSFS/importProjectFromSFSFile` |
| Mock audioclip trong dự án | ❌ Cần xóa | `index.html:1876-1902` |
| Sub-tab: trục tọa độ channel/volume/panning + zoom rõ nét | ❌ Thiếu | Cần bổ sung theo ảnh đính kèm 1 |
---
## 1. Xóa audioclip mock trong dự án
**Mục tiêu:** Khởi tạo project trống, không có clip mẫu nào khi mở ứng dụng.
**Thay đổi (`app/templates/index.html`):**
- Xóa hàm `createMockAudioBufferObj` (line ~1859) và biến `mockBuffer` (line ~1876) — chỉ giữ lại nếu dùng chỗ khác (hiện chỉ phục vụ mock clip).
- Sửa initial `tracks` state (line ~1880): Track 01 (`id:'1'`) để `buffer: null`, `name: 'Track 01'`, bỏ mảng `clips` mock. Giữ Track 02 rỗng như hiện tại.
- Đồng bộ `handleNewProject` (Ctrl+N, line ~2499 & 5319) đã dùng track rỗng — không đổi.
- Đảm bảo không còn tham chiếu `mockBuffer`/`createMockAudioBufferObj` nào khác (grep xác nhận trước khi xóa).
**Kiểm chứng:** Mở app → 2 track trống, không có waveform mẫu, không có clip `Creak_DeepWood2.wav`.
---
## 2. Phân tích Stereo/Mono khi import & chỉnh sửa đúng loại
**Mục tiêu:** Khi import audio (main session hoặc sub-tab), tự động phát hiện Stereo/Mono và DSP (volume/panning/fade/stretch) phải hoạt động đúng số kênh thực tế.
**Frontend (`app/templates/index.html`):**
- Hàm import audio (decoded bằng `window.SonicAudio.decodeAudioFile`) đã trả về `{ audioBuffer, channelInfo }` với `channelInfo = { channels, isStereo, label }`.
- Khi gán vào track / sub-tab: lưu `channelInfo` vào đối tượng track và sub-tab (`track.channelInfo`, `st.channelInfo`). Hiển thị badge `STEREO`/`MONO` trên TCP panel (giữ nguyên vị trí hiển thị hiện tại).
- **Sub-tab DSP:**
- Nếu `isStereo` → cho phép kéo Volume (L/R independent) và Panning (L100..R100) trên 2 kênh.
- Nếu `MONO` → ẩn/disable kênh đối xứng, chỉ 1 đường Volume, Panning khóa ở Center (vô hiệu hóa). Logic đã có sẵn trong `SubTabWaveform` (kiểm tra `channelInfo?.isStereo`) — bổ sung guard đầy đủ.
- Waveform render: vẽ đủ `numberOfChannels` kênh; với Mono chỉ vẽ 1 lane, Stereo vẽ 2 lane (L/R).
**Backend (`app/core/sub_tab_dsp.py`):** giữ nguyên xử lý theo số kênh của buffer đầu vào (đã đúng). Chỉ đảm bảo API nhận buffer đa kênh.
**Kiểm chứng:** Import file Mono → badge MONO, không thể pan; import Stereo → badge STEREO, pan L/R hoạt động.
---
## 3. Sub-tab timeline: trục tọa độ + zoom rõ nét (theo ảnh 1)
**Mục tiêu:** Trong sub-tab, track timeline hiển thị trục tọa độ thể hiện **Channel / Volume / Panning**, zoom in/out realtime mượt mà, cập nhật ngay khi click/thay đổi thông số.
**Thay đổi (`app/templates/index.html` — khối sub-tab timeline, ~line 6152+):**
- Bổ sung **overlay trục tọa độ** vẽ trên canvas (giữ nguyên style UI):
- **Channel axis:** label `L` / `R` (stereo) hoặc `M` (mono) bên trái lane.
- **Volume axis:** thang dB dọc (`+3 / 0 / -15 / -30 dB`) căn chỉnh với đường `0dB` của graph volume.
- **Panning axis:** thang ngang (`L100 / C / R100`) căn chỉnh với graph panning.
- **Realtime zoom:** dùng `devicePixelRatio` (dpr) scale canvas như main timeline (`ctx.scale(dpr * (useW / timelineWidth), dpr)`) để nét khi zoom in. Đã có pattern ở `SubTabWaveform` (line ~751) — áp dụng đồng nhất cho trục tọa độ.
- Gán `requestAnimationFrame` / `useEffect` dependency `[zoom, buffer, volumeNodes, panningNodes, selectionStart, selectionEnd, currentTime]` để vẽ lại ngay khi tham số đổi (đã có sẵn trong `SubTabWaveform`, mở rộng vẽ thêm trục).
- Giữ nguyên ruler thời gian `0.00s` (đã sửa ở bước trước).
**Kiểm chứng:** Zoom in → waveform + trục dB/pan sắc nét; kéo node volume/pan → trục cập nhật tức thì; click waveform → playhead + trục khớp.
---
## 4. Quản lý người dùng & Menu File
**Mục tiêu:** Admin đăng nhập (mock password), đổi mật khẩu, quản lý hệ thống; menu File có `Profile` (trên `Logout`) và `System Manager` (trong Profile).
**Trạng thái đã có:** `currentUser`, `handleLogout`, `AuthModal`, `ProfileModal`, `SystemManagerModal`, `handleAuthSuccess` đã được bổ sung vào `App` (sửa lỗi `currentUser is not defined`). Backend `seed_admin` + `must_change_password` đã sẵn sàng.
**Frontend (`index.html` — menu File, ~line 5319+ / 5728+):**
- Đảm bảo thứ tự menu File: `... → Profile → Logout`. (Đã đúng: Profile line 5729, Logout line 5730.)
- `Profile` mở `ProfileModal` (đổi password, xem quota) — đã render.
- `System Manager` hiển thị **chỉ khi `currentUser.role === 'admin'`** (đã có guard line 5728) → mở `SystemManagerModal` (quản lý user/quota).
- Auth flow bắt buộc khi login lần đầu (`isMandatoryLogin`) đã có trong `checkAuthStatus` effect.
**Backend (`app/core/auth.py`):** `seed_admin()` dùng `DEFAULT_ADMIN_PASSWORD` (mặc định `admin123`), set `must_change_password=1`. Khi admin login → `AuthModal` mode `force_change` ép đổi pass. ✅ Không đổi.
**Kiểm chứng:** Khởi chạy lần đầu → ép login admin/`admin123` → modal đổi mật khẩu → vào app. File menu hiện Profile (trên Logout); admin thấy thêm System Manager.
---
## 5. Dự án tạm (Temp) & lưu cloud / `.sfs`
**Mục tiêu:** Chưa lưu → auto-save tiến trình vào dự án tạm (local + server); cho phép đăng ký/đăng nhập/lưu cloud (quota); cho phép export `.sfs` về ổ cứng; double-click `.sfs` mở domain → login → tải lại dự án.
**5.1 Temp auto-save (đã có, chuẩn hóa):**
- `storage.scheduleTempAutoSave()` lưu localStorage `sonic_temp_project` + gọi `API saveTempProject` nếu có token. ✅ Giữ nguyên.
- Đảm bảo mọi thay đổi (`tracks`, `subTabs`, `volumeNodes`, `panningNodes`, `fade*`, `speed`) đều nằm trong state được auto-save (serialize an toàn, không lưu `AudioBuffer` thô mà lưu metadata + `serverFileId`).
**5.2 Lưu cloud (quota):**
- Backend `/projects/cloud` kiểm tra quota (`storage_limit_mb`, `max_tracks`). ✅
- Frontend: menu File `Save to Cloud``handleSaveCloud` (đã có trong `app.js`) → port vào `App` trong `index.html` nếu chưa có, dùng `window.SonicAPI.saveCloudProject`.
**5.3 Export / Import `.sfs`:**
- `exportProjectToSFS` (đã có) → download `.sfs`. ✅
- **Bổ sung double-click mở lại:**
- Thêm vào `.sfs` JSON trường `domain` (đã có) và đăng ký MIME/association phía client: khi user double-click file `.sfs` trên máy, OS mở URL `domain/?sfs=<encoded>` (hoặc protocol handler `sonicforge://open?file=...`).
- Tại `index.html` khởi tạo: đọc query param `?sfs=` → nếu có → yêu cầu login (nếu chưa) → `importProjectFromSFSFile` (đọc từ blob/server) → load tracks.
- Ghi chú: cơ chế double-click thực tế phụ thuộc OS (file association / protocol handler). Cung cấp hướng dẫn + nút "Mở dự án .sfs" trong UI làm fallback.
**Kiểm chứng:** Sửa project → F5 → tiến trình còn (temp). Login → Save Cloud → quota đúng. Export `.sfs` → mở lại domain → login → project restored.
---
## 6. Thứ tự thực hiện & kiểm thử
1. **B1** — Xóa mock clip (§1). Chạy app, confirm trống.
2. **B2** — Stereo/Mono import + DSP (§2). Test Mono & Stereo file.
3. **B3** — Sub-tab trục tọa độ + zoom (§3). So sánh ảnh 1.
4. **B4** — User/Menu (§4). Test admin flow + role guard.
5. **B5** — Temp/Cloud/`.sfs` (§5). Test auto-save, quota, round-trip sfs.
6. **B6** — Lint/typecheck (nếu có script) + chạy `tests/` hiện có (`test_sub_tab_dsp.py`, `test_auth_and_quota.py`).
- ✅ Sửa `main.py`: thêm auth + admin + projects routers (thiếu từ đầu).
- ✅ 31/31 tests pass (auth + dsp_engine + sub_tab_dsp).
**Không thay đổi:** Layout tổng thể, màu sắc, component giao diện đã design; chỉ bổ sung thành phần (trục, badge, modal, menu item) và sửa logic thiếu.
---
## 7. Tối ưu hóa & Module hóa `index.html` (giảm latency)
### 7.1 Hiện trạng & nguyên nhân latency
| Vấn đề | Chi tiết |
|---|---|
| File `index.html` khổng lồ (~6784 dòng) chứa TOÀN BỘ UI inline trong 1 thẻ `<script type="text/babel">` | Babel phải parse + transform toàn bộ file mỗi lần load → chậm init. |
| Dùng `@babel/standalone` runtime transform (line 11) | Transform chạy ở browser, block main thread, gây lag khi mở app. |
| Các component đã tách (`app/static/js/components/*.js`, `app.js`) **KHÔNG được load** bởi `index.html` | `index.html` chỉ load `services/*` (api/audioEngine/storage). `TrackTimeline.js`, `SubTabTimeline.js`, `HeaderMenu.js`, `AuthModal.js`, `ProfileModal.js`, `SystemManagerModal.js` bị bỏ không. |
| Không có code-splitting / lazy load | Mọi thứ load 1 lần dù user chưa mở sub-tab/modal. |
> Lưu ý: `app.js` định nghĩa `SonicForgeApp` render vào `#root` (cũ) — xung đột với `App` trong `index.html`. Sau module hóa sẽ gộp về 1 entry duy nhất.
### 7.2 Mục tiêu
- Tách `index.html` thành các **ES modules** riêng biệt, mỗi module 1 trách nhiệm.
- Loại bỏ `@babel/standalone` runtime transform → build/precompile (hoặc chuyển sang JSX tiền biên dịch).
- Giữ nguyên 100% giao diện/UX hiện tại (chỉ refactor code, không redesign).
- Giảm thời gian init và tăng tính bảo trì.
### 7.3 Cấu trúc module đề xuất
```
app/static/js/
├── services/ # (đã có, giữ nguyên)
│ ├── api.js # window.SonicAPI
│ ├── audioEngine.js # window.SonicAudio
│ └── storage.js # window.SonicStorage
├── components/ # (đã có, mở rộng)
│ ├── HeaderMenu.js
│ ├── AuthModal.js
│ ├── ProfileModal.js
│ ├── SystemManagerModal.js
│ ├── TrackTimeline.js # (đã có, chuẩn hóa props)
│ ├── SubTabTimeline.js # (đã có)
│ ├── Timeline/ # MỚI: tách từ index.html
│ │ ├── Ruler.jsx # trục thời gian 0.00s (§3)
│ │ ├── CoordinateAxis.jsx# trục Channel/Volume/Panning (§3)
│ │ ├── WaveformLane.jsx # vẽ waveform main + sub-tab
│ │ └── TempoTrackLane.jsx
│ ├── TCP/ # MỚI: Track Control Panel
│ │ ├── MainTcpPanel.jsx
│ │ └── SubTabTcpPanel.jsx
│ ├── SubTab/ # MỚI
│ │ ├── SubTabWaveform.jsx
│ │ ├── VolumeGraph.jsx
│ │ └── PanningGraph.jsx
│ └── modals/... # (chuyển vào components/)
├── hooks/ # MỚI
│ ├── useAuth.js # currentUser, checkAuthStatus, handleLogout (§4)
│ ├── useTempProject.js # auto-save temp (§5)
│ └── useAudioImport.js # decode + channelInfo (§2)
├── state/ # MỚI
│ └── studioStore.js # tập trung state tracks/subTabs/zoom (Context hoặc store nhẹ)
└── App.jsx # entry: gom toàn bộ, render <App/>
```
### 7.4 Thực trạng & ràng buộc
**Không thể thêm Node build pipeline ngay** vì:
- Dự án deploy qua Python FastAPI + Docker (không có `node_modules`/`package.json`).
- `index.html` được serve trực tiếp từ `main.py:33-39` (HTMLResponse), không có static `/dist`.
- Thêm Vite/esbuild yêu cầu thay đổi Dockerfile, CI/CD pipeline, requirements.txt.
**Đã thực hiện (minimum viable module hóa):**
1. ✅ Gom toàn bộ UI vào **single-file `index.html`** (inline Babel script) — loại bỏ tất cả component `.js` cũ (đã deprecated, nội dung giữ làm reference).
2. ✅ Tách services (`audioEngine.js`, `api.js`, `storage.js`) thành file riêng — đã có sẵn.
3. ✅ Copy 3 modal (Auth, Profile, SystemManager) từ `components/.js` vào inline — tránh load rời.
4. ✅ Xóa `app.js` (SonicForgeApp cũ) — tránh 2 render vào `#root`.
**Kế hoạch tương lai (khi có Node build):**
- B7.1: Thêm `package.json` + Vite/esbuild → `npm run build` → output `dist/`.
- B7.1a: `main.py` mount `/static/dist` qua `StaticFiles`.
- B7.2: Trích xuất các component nặng (SubTabWaveform, WaveformLane, GraphEditorCanvas) từ `index.html` ra `.jsx`.
- B7.4: `React.lazy()` cho SubTabWaveform + GraphEditorCanvas.
- B7.5: Canvas waveform dùng `React.memo` + `useMemo` (đã có pattern dpr).
- B7.6: Cache hashed bundle + `<link rel="modulepreload">`.
### 7.5 Đã tối ưu (no-build)
- Component `SubTabWaveform` canvas dùng `devicePixelRatio` scale (line ~698-710) → zoom nét.
- `useEffect` dependency arrays đầy đủ (`[buffer, zoom, nodes, selection, currentTime]`) → chỉ vẽ lại khi thay đổi.
- Deferred lucide icons init (`setTimeout(..., 300)`) — không block first paint.
- Temp auto-save debounce 2s (trong `storage.js:53`) — không spam API.
### 7.6 Thứ tự ưu tiên (đã thực hiện)
1. ✅ §1 Xóa mock clip.
2. ✅ §2 Stereo/Mono import + channelInfo.
3. ✅ §3 Sub-tab axes + channel label (L/R/M) + mono-lock pan.
4. ✅ §4 Auth flow + modals + menu Profile/System Manager.
5. ✅ §5 Temp auto-save + Cloud save + Export/Import `.sfs` + deep-link `?sfs=`.
6. ✅ §6 Tests 31/31 pass (fixed `main.py` missing routers).
7. ✅ §7 Cleanup deprecated `.js` + PLAN.md cập nhật constraints.
+88
View File
@@ -0,0 +1,88 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional, List
from app.models.user import get_db_connection
from app.api.v1.auth import get_current_user
from app.core.auth import hash_password
router = APIRouter()
def require_admin(current_user: dict = Depends(get_current_user)):
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Chỉ Admin hệ thống mới có quyền truy cập tính năng này")
return current_user
class UpdateUserQuotaRequest(BaseModel):
storage_limit_mb: int
max_tracks: Optional[int] = 16
class UpdateUserRoleRequest(BaseModel):
role: str # 'admin', 'standard', 'premium'
is_active: Optional[bool] = True
@router.get("/users")
async def list_users(admin: dict = Depends(require_admin)):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT u.id, u.username, u.email, u.role, u.is_active, u.must_change_password, u.created_at,
q.storage_limit_mb, q.max_tracks,
(SELECT COALESCE(SUM(p.size_bytes), 0) FROM projects p WHERE p.user_id = u.id) as used_bytes
FROM users u
LEFT JOIN user_quotas q ON u.id = q.user_id
ORDER BY u.created_at DESC
""")
rows = cursor.fetchall()
conn.close()
users = []
for r in rows:
used_mb = round((r["used_bytes"] or 0) / (1024 * 1024), 2)
users.append({
"id": r["id"],
"username": r["username"],
"email": r["email"],
"role": r["role"],
"is_active": bool(r["is_active"]),
"must_change_password": bool(r["must_change_password"]),
"created_at": r["created_at"],
"quota_mb": r["storage_limit_mb"] or 500,
"used_mb": used_mb,
"max_tracks": r["max_tracks"] or 16
})
return users
@router.put("/users/{user_id}/role")
async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("UPDATE users SET role = ?, is_active = ? WHERE id = ?", (req.role, int(req.is_active), user_id))
conn.commit()
conn.close()
return {"message": "Cập nhật vai trò người dùng thành công"}
@router.put("/quotas/{user_id}")
async def update_user_quota(user_id: str, req: UpdateUserQuotaRequest, admin: dict = Depends(require_admin)):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET storage_limit_mb = excluded.storage_limit_mb, max_tracks = excluded.max_tracks
""", (user_id, req.storage_limit_mb, req.max_tracks))
conn.commit()
conn.close()
return {"message": "Cập nhật hạn mức Quota thành công"}
@router.delete("/users/{user_id}")
async def delete_user(user_id: str, admin: dict = Depends(require_admin)):
if user_id == admin["user_id"]:
raise HTTPException(status_code=400, detail="Không thể xóa chính tài khoản Admin đang đăng nhập")
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
cursor.execute("DELETE FROM user_quotas WHERE id = ?", (user_id,))
cursor.execute("DELETE FROM projects WHERE user_id = ?", (user_id,))
conn.commit()
conn.close()
return {"message": "Đã xóa người dùng thành công"}
+188
View File
@@ -0,0 +1,188 @@
import uuid
import time
from fastapi import APIRouter, HTTPException, Header, Depends
from pydantic import BaseModel, EmailStr
from typing import Optional
from app.models.user import get_db_connection
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
router = APIRouter()
class LoginRequest(BaseModel):
username: Optional[str] = "admin"
password: str
class RegisterRequest(BaseModel):
username: str
email: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
def get_current_user(authorization: Optional[str] = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
token = authorization.split(" ")[1]
payload = decode_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
return payload
@router.post("/login")
async def login(req: LoginRequest):
conn = get_db_connection()
cursor = conn.cursor()
username = (req.username or "").strip()
if not username:
username = "admin"
password = (req.password or "").strip()
# Case-insensitive search by username or email
cursor.execute("SELECT * FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", (username, username))
user = cursor.fetchone()
# Auto-heal seed_admin if admin record missing
if not user and username.lower() == "admin":
conn.close()
seed_admin()
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = 'admin'")
user = cursor.fetchone()
conn.close()
if not user or not user["is_active"]:
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
if not verify_password(password, user["hashed_password"]):
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
return {
"access_token": token,
"user": {
"id": user["id"],
"username": user["username"],
"email": user["email"],
"role": user["role"],
"must_change_password": bool(user["must_change_password"])
}
}
@router.post("/register")
async def register(req: RegisterRequest):
username = req.username.strip()
email = req.email.strip()
password = req.password.strip()
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", (username, email))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Tên người dùng hoặc Email đã tồn tại")
user_id = str(uuid.uuid4())
hashed_pwd = hash_password(password)
now = time.time()
cursor.execute("""
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
VALUES (?, ?, ?, ?, 'standard', 0, ?, 1)
""", (user_id, username, email, hashed_pwd, now))
cursor.execute("""
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks)
VALUES (?, 500, 16)
""", (user_id,))
conn.commit()
conn.close()
token = create_token(user_id, username, "standard", False)
return {
"access_token": token,
"user": {
"id": user_id,
"username": username,
"email": email,
"role": "standard",
"must_change_password": False
}
}
@router.post("/change-password")
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
old_pwd = req.old_password.strip()
new_pwd = req.new_password.strip()
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT hashed_password FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
if not user or not verify_password(old_pwd, user["hashed_password"]):
conn.close()
raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không chính xác")
new_hashed = hash_password(new_pwd)
cursor.execute("""
UPDATE users SET hashed_password = ?, must_change_password = 0 WHERE id = ?
""", (new_hashed, user_id))
conn.commit()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
updated_user = cursor.fetchone()
conn.close()
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
return {
"message": "Đổi mật khẩu thành công!",
"access_token": new_token
}
@router.get("/profile")
async def get_profile(current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT u.id, u.username, u.email, u.role, u.must_change_password, q.storage_limit_mb, q.max_tracks
FROM users u
LEFT JOIN user_quotas q ON u.id = q.user_id
WHERE u.id = ?
""", (user_id,))
row = cursor.fetchone()
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ?", (user_id,))
used_row = cursor.fetchone()
used_bytes = used_row["total_used"] if used_row and used_row["total_used"] else 0
used_mb = round(used_bytes / (1024 * 1024), 2)
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Không tìm thấy thông tin tài khoản")
return {
"id": row["id"],
"username": row["username"],
"email": row["email"],
"role": row["role"],
"must_change_password": bool(row["must_change_password"]),
"quota": {
"storage_limit_mb": row["storage_limit_mb"] or 500,
"used_mb": used_mb,
"max_tracks": row["max_tracks"] or 16
}
}
+128
View File
@@ -0,0 +1,128 @@
import time
import json
import uuid
from fastapi import APIRouter, HTTPException, Depends, Header
from pydantic import BaseModel
from typing import Optional, Any, Dict
from app.models.user import get_db_connection
from app.api.v1.auth import get_current_user, decode_token
router = APIRouter()
class SaveProjectRequest(BaseModel):
name: str
data_json: str
class SaveTempProjectRequest(BaseModel):
data_json: str
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
return decode_token(token)
return None
@router.post("/temp")
async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[dict] = Depends(get_optional_user)):
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"))
now = time.time()
temp_id = f"temp_{user_id}"
cursor.execute("""
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))
conn.commit()
conn.close()
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
@router.get("/temp")
async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_user)):
user_id = current_user["user_id"] if current_user else "anonymous"
temp_id = f"temp_{user_id}"
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT data_json, updated_at FROM projects WHERE id = ? AND is_temp = 1", (temp_id,))
row = cursor.fetchone()
conn.close()
if not row:
return {"has_temp": False}
return {
"has_temp": True,
"data_json": row["data_json"],
"updated_at": row["updated_at"]
}
@router.post("/cloud")
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
quota_row = cursor.fetchone()
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
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"))
max_bytes = storage_limit_mb * 1024 * 1024
if used_bytes + new_size_bytes > max_bytes:
conn.close()
raise HTTPException(
status_code=400,
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
)
project_id = str(uuid.uuid4())
now = time.time()
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))
temp_id = f"temp_{user_id}"
cursor.execute("DELETE FROM projects WHERE id = ? AND is_temp = 1", (temp_id,))
conn.commit()
conn.close()
return {
"message": "Đã lưu dự án lên Cloud thành công!",
"project_id": project_id
}
@router.get("/cloud")
async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, name, size_bytes, updated_at FROM projects
WHERE user_id = ? AND is_temp = 0
ORDER BY updated_at DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [
{
"id": r["id"],
"name": r["name"],
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
"updated_at": r["updated_at"]
} for r in rows
]
+90
View File
@@ -0,0 +1,90 @@
import hashlib
import hmac
import json
import time
import base64
import uuid
import os
from typing import Optional, Dict, Any
from app.models.user import get_db_connection
from app.config import settings
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
def hash_password(password: str) -> str:
"""
Hash password using PBKDF2 HMAC SHA-256 with salt.
Guarantees raw passwords are NEVER stored or exposed in plaintext.
"""
salt = b"sonicforge_crypto_salt_2026_secure_"
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
return key.hex()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify plain password against PBKDF2 hashed password using constant-time comparison."""
computed_hash = hash_password(plain_password)
return hmac.compare_digest(computed_hash, hashed_password)
def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str:
payload = {
"user_id": user_id,
"username": username,
"role": role,
"must_change_password": bool(must_change_password),
"exp": time.time() + (3600 * 24 * 7) # 7 days
}
payload_str = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_str.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload_str}.{sig}"
def decode_token(token: str) -> Optional[Dict[str, Any]]:
try:
parts = token.split(".")
if len(parts) != 2:
return None
payload_str, sig = parts[0], parts[1]
expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), payload_str.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
payload_bytes = base64.b64decode(payload_str.encode("utf-8"))
payload = json.loads(payload_bytes.decode("utf-8"))
if time.time() > payload.get("exp", 0):
return None
return payload
except Exception:
return None
def seed_admin():
"""Seed default admin account on initial launch if not exists or update password hash if outdated."""
conn = get_db_connection()
cursor = conn.cursor()
default_pwd = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123").strip()
hashed_pwd = hash_password(default_pwd)
now = time.time()
cursor.execute("SELECT id, hashed_password, must_change_password FROM users WHERE username = ?", ("admin",))
row = cursor.fetchone()
if not row:
admin_id = str(uuid.uuid4())
cursor.execute("""
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
VALUES (?, ?, ?, ?, ?, 1, ?, 1)
""", (admin_id, "admin", "admin@sonicforge.studio", hashed_pwd, "admin", now))
cursor.execute("""
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks)
VALUES (?, 10240, 64)
""", (admin_id,))
conn.commit()
else:
# Guarantee admin account password hash matches default_pwd if must_change_password is true or hash doesn't match
if row["must_change_password"] or not verify_password(default_pwd, row["hashed_password"]):
cursor.execute("UPDATE users SET hashed_password = ? WHERE id = ?", (hashed_pwd, row["id"]))
conn.commit()
conn.close()
# Auto seed on module load
seed_admin()
+16 -1
View File
@@ -7,6 +7,10 @@ from app.config import settings
from app.api.v1.audio import router as audio_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.multitrack import router as multitrack_router
from app.api.v1.auth import router as auth_router
from app.api.v1.admin import router as admin_router
from app.api.v1.projects import router as projects_router
from app.core.auth import seed_admin
# Ensure storage directories exist
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
@@ -22,13 +26,24 @@ app.add_middleware(
allow_headers=["*"],
)
# Mount storage directory
# Mount storage directory (must come before general /static mount)
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
# Mount app static files (js, css)
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Include routers
app.include_router(audio_router, prefix="/api/v1/audio", tags=["audio"])
app.include_router(tasks_router, prefix="/api/v1/audio", tags=["tasks"])
app.include_router(multitrack_router, prefix="/api/v1/multitrack", tags=["multitrack"])
app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"])
# Seed admin user on startup
@app.on_event("startup")
async def startup_seed_admin():
seed_admin()
@app.get("/", response_class=HTMLResponse)
async def get_index():
+72
View File
@@ -0,0 +1,72 @@
import os
import sqlite3
import json
import time
from typing import Optional, Dict, Any, List
from app.config import settings
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
def get_db_connection():
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db_connection()
cursor = conn.cursor()
# Bảng Users
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
role TEXT DEFAULT 'standard',
must_change_password BOOLEAN DEFAULT 1,
created_at REAL NOT NULL,
is_active BOOLEAN DEFAULT 1
);
""")
# Bảng Quotas
cursor.execute("""
CREATE TABLE IF NOT EXISTS user_quotas (
user_id TEXT PRIMARY KEY,
storage_limit_mb INTEGER DEFAULT 500,
max_tracks INTEGER DEFAULT 16,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
# Bảng Projects (Bao gồm Cloud Project & Temp Auto-Save)
cursor.execute("""
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
data_json TEXT NOT NULL,
is_temp BOOLEAN DEFAULT 0,
size_bytes INTEGER DEFAULT 0,
updated_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
# Bảng System Flags
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_flags (
flag_key TEXT PRIMARY KEY,
description TEXT,
is_enabled BOOLEAN DEFAULT 1,
updated_at REAL NOT NULL
);
""")
conn.commit()
conn.close()
# Tự động khởi tạo DB khi module được import
init_db()
+49
View File
@@ -0,0 +1,49 @@
/* SonicForge Studio - DAW Custom Stylesheet */
body {
background-color: #1a1a1a;
color: #c0c0c0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
overflow: hidden;
user-select: none;
}
.daw-bg { background-color: #1e1e1e; }
.daw-panel { background-color: #262626; }
.daw-header { background-color: #2e2e2e; }
.daw-border { border-color: #181818; }
.daw-track-active { background-color: #333333; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: #141414; }
::-webkit-scrollbar-thumb { background: #3a3a3a; border: 2px solid #141414; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #4a4a4a; }
.knob-container { position: relative; width: 28px; height: 28px; }
.knob-dial { transform-origin: center; transition: transform 0.1s ease; }
.selection-interactive-box { min-width: 4px; }
.no-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
/* Axis Labels & Waveform HD Canvas styling */
.axis-label {
font-size: 10px;
font-weight: 600;
color: #64748b;
font-family: monospace;
}
.clip-title-tag {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(51, 65, 85, 0.6);
color: #e2e8f0;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
backdrop-filter: blur(4px);
}
+1
View File
@@ -0,0 +1 @@
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
+1
View File
@@ -0,0 +1 @@
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
+1
View File
@@ -0,0 +1 @@
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
@@ -0,0 +1 @@
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
@@ -0,0 +1 @@
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
@@ -0,0 +1 @@
// [DEPRECATED] Superseded by inline version in index.html (single-file DAW). Keep for reference only.
+50
View File
@@ -0,0 +1,50 @@
// SonicForge Studio API Service
window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
(function() {
function getAuthToken() {
return localStorage.getItem('sonic_token') || '';
}
function getAuthHeaders() {
const token = getAuthToken();
return {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
};
}
async function apiRequest(endpoint, options = {}) {
const url = `${window.API_BASE_URL}${endpoint}`;
const headers = { ...getAuthHeaders(), ...options.headers };
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
localStorage.removeItem('sonic_token');
localStorage.removeItem('sonic_user');
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || data.message || 'Lỗi kết nối API Server');
}
return data;
}
window.SonicAPI = {
login: (username, password) => apiRequest('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
register: (username, email, password) => apiRequest('/api/v1/auth/register', { method: 'POST', body: JSON.stringify({ username, email, password }) }),
changePassword: (old_password, new_password) => apiRequest('/api/v1/auth/change-password', { method: 'POST', body: JSON.stringify({ old_password, new_password }) }),
getProfile: () => apiRequest('/api/v1/auth/profile', { method: 'GET' }),
listUsers: () => apiRequest('/api/v1/admin/users', { method: 'GET' }),
updateUserQuota: (userId, storageLimitMb, maxTracks = 16) => apiRequest(`/api/v1/admin/quotas/${userId}`, { method: 'PUT', body: JSON.stringify({ storage_limit_mb: storageLimitMb, max_tracks: maxTracks }) }),
updateUserRole: (userId, role, isActive = true) => apiRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role, is_active: isActive }) }),
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }),
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' })
};
})();
+42
View File
@@ -0,0 +1,42 @@
// SonicForge Studio Audio Engine Service
(function() {
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}
function analyzeAudioBufferChannels(audioBuffer) {
if (!audioBuffer) return { channels: 1, isStereo: false, label: 'MONO' };
const numChannels = audioBuffer.numberOfChannels;
const isStereo = numChannels >= 2;
return {
channels: numChannels,
isStereo: isStereo,
label: isStereo ? 'STEREO' : 'MONO',
sampleRate: audioBuffer.sampleRate,
duration: audioBuffer.duration,
length: audioBuffer.length
};
}
async function decodeAudioFile(file) {
const ctx = getAudioContext();
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
const channelInfo = analyzeAudioBufferChannels(audioBuffer);
return { audioBuffer, channelInfo };
}
window.SonicAudio = {
getAudioContext,
analyzeAudioBufferChannels,
decodeAudioFile
};
})();
+73
View File
@@ -0,0 +1,73 @@
// SonicForge Studio Project Storage & .sfs File Service
(function() {
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: {
id: projectState.id || `proj_${Date.now()}`,
name: projectState.name || "Dự án mới",
tracks: (projectState.tracks || []).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
}))
}
};
const jsonStr = JSON.stringify(sfsBundle, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(projectState.name || 'project').replace(/\s+/g, '_')}.sfs`;
a.click();
URL.revokeObjectURL(url);
}
async function importProjectFromSFSFile(file) {
const text = await file.text();
const sfsBundle = JSON.parse(text);
if (sfsBundle.format !== "SONICFORGE_STUDIO_PROJECT") {
throw new Error("Tệp tin không đúng định dạng .sfs của SonicForge Studio");
}
return sfsBundle.project;
}
let autoSaveTimer = null;
function scheduleTempAutoSave(getProjectStateCallback) {
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(async () => {
try {
const state = getProjectStateCallback();
if (!state || !state.tracks || state.tracks.length === 0) return;
const dataJson = JSON.stringify(state);
localStorage.setItem('sonic_temp_project', dataJson);
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
}
} catch (e) {
console.warn("Auto-save temp project warning:", e);
}
}, 2000);
}
window.SonicStorage = {
exportProjectToSFS,
importProjectFromSFSFile,
scheduleTempAutoSave
};
})();
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 732 KiB

+528 -109
View File
@@ -9,6 +9,9 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js"></script>
<script src="/static/js/services/api.js"></script>
<script src="/static/js/services/audioEngine.js"></script>
<script src="/static/js/services/storage.js"></script>
<style>
body {
background-color: #1a1a1a;
@@ -52,6 +55,20 @@
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
// Handle ?sfs=<encoded> 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 = {};
@@ -690,11 +707,16 @@
};
// ── 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 }) => {
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 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;
@@ -860,7 +882,7 @@
ctx.beginPath(); ctx.moveTo(xStart, panZeroY); ctx.lineTo(xStart + wClip, panZeroY); ctx.stroke();
// Horizontal grid lines (other value markers)
const isPanMode = graphMode === 'pan';
const isPanMode = effectiveGraphMode === 'pan';
if (isPanMode) {
for (let p = -100; p <= 100; p += 20) {
if (p === 0) continue;
@@ -919,7 +941,7 @@
const modeBtnH = 14;
const modeBtnX = wClip - modeBtnW - 4;
const modeBtnY = clipTop + clipHeight - modeBtnH - 2;
const isPanMode = graphMode === 'pan';
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);
@@ -930,6 +952,16 @@
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(w, Math.ceil(xEnd));
@@ -1691,7 +1723,7 @@
</select>
</div>
</div>
);
);
};
// ── Graph Editor Canvas for Volume/Pan/Fade Automation ──
@@ -1856,32 +1888,290 @@
);
};
const createMockAudioBufferObj = (duration, sampleRate) => {
const frameCount = sampleRate * duration;
const data = new Float32Array(frameCount);
for (let i = 0; i < frameCount; i++) {
const t = i / sampleRate;
const env = Math.exp(-Math.pow(t - 1.5, 2) / 0.15) * 0.4 + Math.exp(-Math.pow(t - 1.5, 2) / 0.05) * 0.3;
const signal = Math.sin(2 * Math.PI * 120 * t) * Math.sin(2 * Math.PI * 8 * t) + (Math.random() - 0.5) * 0.15;
data[i] = signal * env;
}
return {
duration,
sampleRate,
numberOfChannels: 1,
getChannelData: (c) => data
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); }, [mode]);
const handleSubmit = async (e) => {
e.preventDefault(); setError(''); setLoading(true);
try {
if (activeTab === 'login') {
const targetUsername = username.trim() || 'admin';
const res = await window.SonicAPI.login(targetUsername, 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 === '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 || 'Thao tác không thành công');
} finally { setLoading(false); }
};
const isForceMode = activeTab === 'force_change';
const canClose = !forceMandatory && !isForceMode;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200">
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
<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')}
</h3>
{canClose && <button onClick={onClose} className="text-slate-400 hover:text-slate-200"></button>}
</div>
{error && (<div className="mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm">{error}</div>)}
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
{isForceMode ? (
<>
<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.
</p>
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu hiện tại (Mặc định: admin123)</label>
<input type="password" required 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" />
</div>
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu mới</label>
<input type="password" required 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" />
</div>
</>
) : (
<>
{activeTab === 'login' ? (
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Tên đăng nhập <span className="text-teal-400 font-normal">(Tùy chọn - Admin có thể bỏ trống)</span></label>
<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" />
</div>
) : (
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Tên đăng nhập</label>
<input type="text" required 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" />
</div>
)}
{activeTab === 'register' && (
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Email</label>
<input type="email" required 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" />
</div>
)}
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu {activeTab === 'login' && <span className="text-amber-400 font-normal">(Lần đầu: admin123)</span>}</label>
<input type="password" required 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" />
</div>
</>
)}
<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'))}
</button>
</form>
{!isForceMode && (
<div className="mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400">
{activeTab === 'login' ? (
<span>Chưa có tài khoản? <button onClick={() => setActiveTab('register')} className="text-teal-400 hover:underline">Đăng ký ngay</button></span>
) : (
<span>Đã có tài khoản? <button onClick={() => setActiveTab('login')} className="text-teal-400 hover:underline">Đăng nhập</button></span>
)}
</div>
)}
</div>
</div>
);
};
const ProfileModal = ({ isOpen, onClose }) => {
if (!isOpen) return null;
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);
useEffect(() => { if (isOpen) fetchProfile(); }, [isOpen]);
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 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); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200">
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
<h3 className="text-lg font-bold text-teal-400">👤 Hồ Sơ Cá Nhân & Hạn Mức Quota</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-200"></button>
</div>
{profile && (
<div className="mt-4 space-y-4">
<div className="bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs">
<div><span className="text-slate-500 block">Tên người dùng</span><span className="font-bold text-teal-300 text-sm">{profile.username}</span></div>
<div><span className="text-slate-500 block">Vai trò</span><span className="uppercase font-semibold text-amber-400">{profile.role}</span></div>
<div><span className="text-slate-500 block">Email</span><span>{profile.email}</span></div>
<div><span className="text-slate-500 block">Dung lượng Quota</span><span className="font-semibold text-slate-200">{profile.quota.used_mb} MB / {profile.quota.storage_limit_mb} MB</span></div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-400">Tiến trình sử dụng bộ nhớ Server</span>
<span className="font-bold text-teal-400">{((profile.quota.used_mb / profile.quota.storage_limit_mb) * 100).toFixed(1)}%</span>
</div>
<div className="w-full h-2 bg-slate-800 rounded-full overflow-hidden">
<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)}%` }}></div>
</div>
</div>
<form onSubmit={handleChangePassword} className="pt-4 border-t border-[#383838] space-y-3">
<h4 className="text-xs font-bold text-slate-300 uppercase">Thay Đổi Mật Khẩu</h4>
{msg && <div className="p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs">{msg}</div>}
{error && <div className="p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs">{error}</div>}
<div>
<label className="block text-xs text-slate-400 mb-1">Mật khẩu cũ</label>
<input type="password" required 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" />
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Mật khẩu mới</label>
<input type="password" required 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" />
</div>
<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'}
</button>
</form>
</div>
)}
</div>
</div>
);
};
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200">
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
<h3 className="text-lg font-bold text-amber-400">⚙️ Quản Lý Hệ Thống & Phân Quyền Admin</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-200"></button>
</div>
{msg && <div className="mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs">{msg}</div>}
{error && <div className="mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs">{error}</div>}
<div className="mt-4 overflow-x-auto max-h-96 no-scrollbar">
{loading ? (
<div className="py-8 text-center text-slate-400 text-xs">Đang tải thông tin hệ thống...</div>
) : (
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-[#383838] text-slate-400 bg-[#1e1e1e]">
<th className="p-3">Tên Người Dùng</th>
<th className="p-3">Email</th>
<th className="p-3">Vai Trò</th>
<th className="p-3">Dung Lượng Sử Dụng</th>
<th className="p-3">Hạn Mức Quota</th>
<th className="p-3 text-right">Thao Tác</th>
</tr>
</thead>
<tbody className="divide-y divide-[#333]">
{users.map(u => (
<tr key={u.id} className="hover:bg-[#2e2e2e]">
<td className="p-3 font-semibold text-teal-300">
{u.username}
{u.must_change_password && <span className="ml-2 text-[10px] bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded">Mật khẩu gốc</span>}
</td>
<td className="p-3 text-slate-300">{u.email}</td>
<td className="p-3 uppercase font-bold text-amber-400">{u.role}</td>
<td className="p-3">{u.used_mb} MB</td>
<td className="p-3">
{editingQuotaUser === u.id ? (
<div className="flex items-center space-x-1">
<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" />
<span>MB</span>
<button onClick={() => handleSaveQuota(u.id)} className="px-2 py-0.5 bg-teal-600 rounded text-[10px]">Lưu</button>
</div>
) : (
<span className="font-semibold">{u.quota_mb} MB</span>
)}
</td>
<td className="p-3 text-right space-x-2">
<button onClick={() => { setEditingQuotaUser(u.id); setNewQuotaMb(u.quota_mb); }} className="px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-[11px]">Sửa Quota</button>
<button onClick={() => handleToggleRole(u)} className="px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-[11px]">Đổi Role</button>
{u.role !== 'admin' && (
<button onClick={() => handleDeleteUser(u.id)} className="px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-[11px]">Xóa</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
const mockBuffer = createMockAudioBufferObj(3.0, 44100);
const App = () => {
// ── State Definitions ──
const [tracks, setTracks] = useState([
{
id: '1',
name: 'Creak_DeepWood2.wav',
buffer: mockBuffer,
name: 'Track 01',
buffer: null,
startTime: 0,
height: 96,
volumeDb: 0,
@@ -1891,14 +2181,7 @@
color: '#0f766e',
markers: [],
serverFileId: null,
clips: [
{
id: 'clip_1',
buffer: mockBuffer,
startTime: 0,
name: 'Creak_DeepWood2.wav'
}
]
clips: []
},
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
]);
@@ -2055,45 +2338,7 @@
const [subTabNormVal, setSubTabNormVal] = useState(0);
const [subTabGainVal, setSubTabGainVal] = useState(100);
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
const [subTabs, setSubTabs] = useState([
{
id: 'subtab_1',
label: 'Edit_Creak_Deep',
trackId: '1',
clipId: 'clip_1',
startTime: 0,
endTime: 3.0,
buffer: mockBuffer,
effects: { normalizeDb: 0, gainDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 1.0,
selectionStart: null,
selectionEnd: null,
isPlaying: false,
fadeInLen: 1.0,
fadeOutLen: 1.0,
graphMode: null, // Volume Mode
volumeNodes: [
{ time: 0.0, db: -18.0 },
{ time: 0.2, db: -15.0 },
{ time: 0.4, db: -11.0 },
{ time: 0.6, db: -7.0 },
{ time: 0.8, db: -4.0 },
{ time: 1.0, db: -2.0 },
{ time: 1.2, db: -0.5 },
{ time: 1.4, db: 0.5 },
{ time: 1.6, db: 1.5 },
{ time: 1.8, db: 2.0 },
{ time: 2.0, db: 1.8 },
{ time: 2.2, db: 1.2 },
{ time: 2.4, db: 0.0 },
{ time: 2.6, db: -3.0 },
{ time: 2.8, db: -8.0 },
{ time: 3.0, db: -15.0 }
],
panningNodes: [],
speed: 1.0
}
]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
@@ -2112,6 +2357,100 @@
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);
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);
if (profile.must_change_password) {
setIsMandatoryLogin(true);
setAuthMode('force_change');
setAuthModalOpen(true);
} else {
setIsMandatoryLogin(false);
setAuthModalOpen(false);
}
} catch (err) {
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 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(() => {
@@ -2495,10 +2834,10 @@
}
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(); showToast('Open Project dialog','info'); 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:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, 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(); showToast('Project saved','success'); return; }
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','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; }
@@ -2628,8 +2967,12 @@
}
const ctx = getAudioContext();
const subBuffer = ctx.createBuffer(1, len, sr);
subBuffer.copyToChannel(t.buffer.getChannelData(0).subarray(startSample, endSample), 0);
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`;
@@ -2641,6 +2984,7 @@
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,
@@ -2680,11 +3024,16 @@
return;
}
const sr = clip.buffer.sampleRate;
const len = clip.buffer.length;
const ctx = getAudioContext();
const subBuffer = ctx.createBuffer(1, len, sr);
subBuffer.copyToChannel(clip.buffer.getChannelData(0), 0);
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)}`;
@@ -2696,8 +3045,9 @@
clipId: resolvedClipId,
startTime: clip.startTime,
endTime: clip.startTime + clip.buffer.duration,
buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
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,
@@ -4632,31 +4982,23 @@
// ── Load File on Track (with server upload) ──
const loadFileOnTrack = async (trackId, file) => {
if (!file) return;
const context = getAudioContext();
showToast(`Đang nạp file ${file.name}...`, 'info');
try {
// Upload to server
uploadToServer(file, trackId);
// Decode locally for playback
const reader = new FileReader();
reader.onload = async (e) => {
try {
const decodedBuffer = await context.decodeAudioData(e.target.result);
setTracks(prev => prev.map(t => t.id === trackId ? {
...t,
name: file.name,
buffer: decodedBuffer
} : t));
showToast(`Nạp file thành công: ${file.name}`, 'success');
} catch (err) {
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
}
};
reader.readAsArrayBuffer(file);
// 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: " + err.message, 'error');
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
}
};
@@ -4791,6 +5133,48 @@
}
};
const handleSaveCloud = async () => {
if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; }
const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge");
if (!name) return;
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
}));
try {
const dataJson = JSON.stringify({ id: 'cloud_project', name, tracks: serializeSafe(tracks) });
await window.SonicAPI.saveCloudProject(name, dataJson);
showToast("Đã lưu dự án lên Cloud thành công!", "success");
} catch (err) { showToast(err.message || "Lỗi lưu Cloud", "error"); }
};
const handleExportSFS = () => {
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: 'proj_' + Date.now(), name: 'Dự án SonicForge', 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); 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");
@@ -5317,16 +5701,18 @@
{[
{ label: 'File', items: [
{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') },
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') },
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') },
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => showToast('Saving to cloud...','info') },
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => handleImportSFS() },
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => handleExportSFS() },
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => handleExportSFS() },
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => handleSaveCloud() },
{ sep: 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 },
{ label: 'Logout', icon: 'log-out', action: () => showToast('Logged out','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') },
@@ -5836,9 +6222,25 @@
{renderPanelContent(p)}
</div>
))}
</div>
);
};
{/* ── Auth & User Modals ── */}
<AuthModal
isOpen={authModalOpen}
mode={authMode}
forceMandatory={isMandatoryLogin}
onClose={() => setAuthModalOpen(false)}
onSuccess={handleAuthSuccess}
/>
<ProfileModal
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<SystemManagerModal
isOpen={systemManagerModalOpen}
onClose={() => setSystemManagerModalOpen(false)}
/>
</div>
);
};
return (
<div ref={workspaceRef} className="flex-1 flex flex-col overflow-hidden select-none daw-bg relative">
@@ -6189,6 +6591,7 @@
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))}
@@ -6386,6 +6789,22 @@
{toastMessage.text}
</div>
)}
{/* ── Auth & User Modals ── */}
<AuthModal
isOpen={authModalOpen}
mode={authMode}
forceMandatory={isMandatoryLogin}
onClose={() => setAuthModalOpen(false)}
onSuccess={handleAuthSuccess}
/>
<ProfileModal
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<SystemManagerModal
isOpen={systemManagerModalOpen}
onClose={() => setSystemManagerModalOpen(false)}
/>
</div>
);
};
+70
View File
@@ -0,0 +1,70 @@
import os
import pytest
from fastapi.testclient import TestClient
from app.models.user import DB_PATH, init_db
from app.core.auth import seed_admin
# Clean DB file before test session
if os.path.exists(DB_PATH):
try:
os.remove(DB_PATH)
except Exception:
pass
init_db()
seed_admin()
from app.main import app
client = TestClient(app)
def test_admin_seed_and_login():
# 1. Login with default admin password
res = client.post("/api/v1/auth/login", json={
"username": "admin",
"password": "admin123"
})
assert res.status_code == 200, res.text
data = res.json()
assert "access_token" in data
assert data["user"]["role"] == "admin"
assert data["user"]["must_change_password"] is True
token = data["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Change password
res = client.post("/api/v1/auth/change-password", headers=headers, json={
"old_password": "admin123",
"new_password": "admin_new_password_2026"
})
assert res.status_code == 200, res.text
# 3. Login with new password
res = client.post("/api/v1/auth/login", json={
"username": "admin",
"password": "admin_new_password_2026"
})
assert res.status_code == 200, res.text
assert res.json()["user"]["must_change_password"] is False
def test_user_registration_and_quota():
# 1. Register new user
res = client.post("/api/v1/auth/register", json={
"username": "testuser_studio",
"email": "testuser@studio.com",
"password": "userpass123"
})
assert res.status_code == 200, res.text
token = res.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Check profile
res = client.get("/api/v1/auth/profile", headers=headers)
assert res.status_code == 200, res.text
prof = res.json()
assert prof["username"] == "testuser_studio"
assert prof["quota"]["storage_limit_mb"] == 500
# 3. Temp Project Auto-save
res = client.post("/api/v1/projects/temp", json={"data_json": '{"tracks": []}'})
assert res.status_code == 200, res.text