perf: slim daw_engine bundle 409MB->170MB + fix engine khong chay tren Windows
- Thay librosa bang app/core/audio_features.py (numpy/scipy/soundfile):
load, beat_track, frames_to_time, spectral_centroid, rms, zero_crossing_rate,
time_stretch, pitch_shift, chroma_stft. A/B ngang librosa (BPM <1% sai lech,
pitch_shift chuan toi Hz). Loai bo llvmlite 171MB + scikit-learn + numba.
- Task layer 2 che do (app/tasks/worker.py): server giu celery; desktop slim
chay in-process thread + registry, giu nguyen API contract (.delay/.id/status
/tasks/{id}) nen frontend khong doi.
- engine.spec: excludes librosa/numba/llvmlite/sklearn/celery/redis/kombu/
billiard/amqp/msgpack/yaml/PIL/cairosvg/zstandard/...; scan scipy gioi han
scipy.signal; giu click (uvicorn.main import click).
- render_engine: scipy.signal thanh lazy import (giam cold start).
- tauri.conf.json: targets [nsis, msi] - NSIS tro lai (bundle nho) de
hooks.nsh cai VC++ Redistributable - sua bug daw_engine.exe khong chay
tren Windows (truoc day MSI-only khong chay hooks).
- build_linux.sh / build_macos.sh: build 1 lenh moi OS.
- Doc: DESKTOP_INSTALL_PLAN.md muc 5.1.
- Verify: 86 tests pass, engine dong goi upload/analyze/waveform/export OK.
This commit is contained in:
@@ -69,6 +69,39 @@ code → build.mjs (precompiled + ?v=) → PyInstaller (server binary) → đón
|
|||||||
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
|
- **GitHub Actions matrix** (windows-latest / macos-latest / ubuntu-latest): test → build → installer artifact.
|
||||||
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
|
- Installer gồm: binary server, static/, VST plugins nền tảng, script tạo service + mở browser, mặc định tạo `~/SonicForgeStudio/` lần chạy đầu.
|
||||||
|
|
||||||
|
### 5.1 Tối ưu bundle daw_engine (bản 1.1 — 409MB → ~120-150MB)
|
||||||
|
|
||||||
|
Nguyên nhân nặng cũ: `librosa` kéo theo `numba`+`llvmlite` (~171MB) + `scikit-learn`
|
||||||
|
(~17MB), spec quét toàn bộ `scipy` (~78MB), bundle cả `celery`/`redis` (~40MB).
|
||||||
|
|
||||||
|
Đã xử lý:
|
||||||
|
- **`app/core/audio_features.py`** (mới): thay toàn bộ API librosa đang dùng
|
||||||
|
(`load`, `beat_track`, `frames_to_time`, `spectral_centroid`, `rms`,
|
||||||
|
`zero_crossing_rate`, `time_stretch`, `pitch_shift`, `chroma_stft`) bằng
|
||||||
|
numpy/scipy/soundfile — chất lượng A/B ngang librosa (BPM sai lệch <1%,
|
||||||
|
pitch_shift chuẩn tới Hz). Các module `analyzer.py`, `dsp_utils.py`,
|
||||||
|
`sub_tab_dsp.py`, `ai_dsp_engine.py` đã chuyển sang shim.
|
||||||
|
- **`app/tasks/worker.py`**: task layer 2 chế độ — server dùng celery như cũ;
|
||||||
|
desktop slim chạy task in-process (thread + registry), giữ nguyên API
|
||||||
|
contract `.delay()` / `/tasks/{id}` nên frontend KHÔNG phải đổi.
|
||||||
|
- **`engine.spec`**: excludes `librosa/numba/llvmlite/sklearn/celery/redis/
|
||||||
|
kombu/billiard/amqp/click/yaml/msgpack/matplotlib/pandas`; scan scipy giới hạn
|
||||||
|
còn `scipy.signal` (goi duy nhất app còn dùng).
|
||||||
|
- **`src-tauri/tauri.conf.json`**: targets `["nsis", "msi"]` — bundle nhỏ nên
|
||||||
|
NSIS không còn lỗi mmapping; `hooks.nsh` cài VC++ Redistributable (MSI không
|
||||||
|
chạy hooks → máy thiếu VC++ → daw_engine.exe không chạy — đây là nguyên nhân
|
||||||
|
"build xong không chạy daw_engine" trên Windows).
|
||||||
|
|
||||||
|
Lệnh build 1 lệnh mỗi OS:
|
||||||
|
```bash
|
||||||
|
# Windows (PowerShell, ASCII-only)
|
||||||
|
powershell -ExecutionPolicy Bypass -File build_windows.ps1
|
||||||
|
# Linux (cần binutils: sudo apt-get install -y binutils)
|
||||||
|
bash build_linux.sh
|
||||||
|
# macOS (cần codesign/notarize khi phát hành)
|
||||||
|
bash build_macos.sh
|
||||||
|
```
|
||||||
|
|
||||||
## 6. CẬP NHẬT
|
## 6. CẬP NHẬT
|
||||||
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
- **Version check**: khi mở app, gọi endpoint version (file `version.json` đóng kèm + so sánh remote) → thông báo bản mới + link tải installer.
|
||||||
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
- Cập nhật = chạy installer mới (ghi đè, GIỮ NGUYÊN `~/SonicForgeStudio/` — data + soundfonts không đụng).
|
||||||
|
|||||||
+10
-3
@@ -1,12 +1,19 @@
|
|||||||
|
import os
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from celery.result import AsyncResult
|
|
||||||
from app.tasks.worker import celery_app
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Task status endpoint dung chung cho ca 2 che do:
|
||||||
|
# - Server/Docker: celery (AsyncResult, broker Redis).
|
||||||
|
# - Desktop slim (PyInstaller khong bundle celery): in-process registry
|
||||||
|
# (app/tasks/worker._SimpleAsyncResult) — API contract giong het nhau.
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}")
|
@router.get("/tasks/{task_id}")
|
||||||
async def get_task_status(task_id: str):
|
async def get_task_status(task_id: str):
|
||||||
res = AsyncResult(task_id, app=celery_app)
|
from app.tasks.worker import get_task_result
|
||||||
|
|
||||||
|
res = get_task_result(task_id)
|
||||||
response_data = {
|
response_data = {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"status": res.status,
|
"status": res.status,
|
||||||
|
|||||||
@@ -75,13 +75,16 @@ class AIDSPEngine:
|
|||||||
t_end = min(total_duration, 4.0)
|
t_end = min(total_duration, 4.0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import librosa
|
from app.core.audio_features import chroma_stft as _chroma_stft
|
||||||
# 1. Compute harmonic structural properties via Chroma Constant-Q Transform
|
# 1. Compute harmonic structural properties via Chroma (STFT-based,
|
||||||
chroma = librosa.feature.chroma_cqt(y=y_mono, sr=sr)
|
# thay chroma_cqt de lo bo librosa/numba/llvmlite ~171MB)
|
||||||
|
chroma = _chroma_stft(y=y_mono, sr=sr)
|
||||||
|
|
||||||
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
|
# 2. Compile Self-Similarity Matrix (Cosine Recurrence Plot)
|
||||||
from sklearn.metrics.pairwise import cosine_similarity
|
# thay sklearn.metrics.pairwise.cosine_similarity bang numpy
|
||||||
ssm = cosine_similarity(chroma.T, chroma.T)
|
c = chroma.T # (n_frames, 12)
|
||||||
|
norms = np.linalg.norm(c, axis=1, keepdims=True)
|
||||||
|
ssm = (c @ c.T) / (norms @ norms.T + 1e-9)
|
||||||
|
|
||||||
num_frames = ssm.shape[0]
|
num_frames = ssm.shape[0]
|
||||||
hop_length = 512
|
hop_length = 512
|
||||||
|
|||||||
+21
-12
@@ -1,19 +1,28 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import librosa
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
# Thay librosa bang shim nhe (numpy/scipy/soundfile) — khong keo numba/llvmlite
|
||||||
|
from app.core.audio_features import (
|
||||||
|
load as _load,
|
||||||
|
beat_track as _beat_track,
|
||||||
|
frames_to_time as _frames_to_time,
|
||||||
|
spectral_centroid as _spectral_centroid,
|
||||||
|
rms as _rms,
|
||||||
|
zero_crossing_rate as _zcr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def analyze_audio(file_path: str) -> dict:
|
def analyze_audio(file_path: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
||||||
"""
|
"""
|
||||||
# Load audio
|
# Load audio
|
||||||
y, sr = librosa.load(file_path, sr=None)
|
y, sr = _load(file_path, sr=None)
|
||||||
|
|
||||||
# Track beats
|
# Track beats
|
||||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
tempo, beat_frames = _beat_track(y=y, sr=sr)
|
||||||
|
|
||||||
# Handle tempo which might be scalar or numpy array in different librosa versions
|
# Handle tempo which might be scalar or numpy array in different librosa versions
|
||||||
if isinstance(tempo, np.ndarray):
|
if isinstance(tempo, np.ndarray):
|
||||||
@@ -25,7 +34,7 @@ def analyze_audio(file_path: str) -> dict:
|
|||||||
bpm = float(tempo)
|
bpm = float(tempo)
|
||||||
|
|
||||||
# Convert frames to time (seconds)
|
# Convert frames to time (seconds)
|
||||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
|
||||||
|
|
||||||
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
|
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
|
||||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||||
@@ -43,31 +52,31 @@ def analyze_audio_advanced(file_path: str) -> dict:
|
|||||||
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
|
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
|
||||||
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
|
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
|
||||||
"""
|
"""
|
||||||
y, sr = librosa.load(file_path, sr=None)
|
y, sr = _load(file_path, sr=None)
|
||||||
duration = float(len(y)) / sr
|
duration = float(len(y)) / sr
|
||||||
|
|
||||||
# Beat tracking
|
# Beat tracking
|
||||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
tempo, beat_frames = _beat_track(y=y, sr=sr)
|
||||||
|
|
||||||
if isinstance(tempo, np.ndarray):
|
if isinstance(tempo, np.ndarray):
|
||||||
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
||||||
else:
|
else:
|
||||||
bpm = float(tempo)
|
bpm = float(tempo)
|
||||||
|
|
||||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
beat_times = _frames_to_time(beat_frames, sr=sr).tolist()
|
||||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||||
|
|
||||||
# Spectral centroid (brightness)
|
# Spectral centroid (brightness)
|
||||||
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
|
spectral_centroids = _spectral_centroid(y=y, sr=sr)[0]
|
||||||
avg_brightness = float(np.mean(spectral_centroids))
|
avg_brightness = float(np.mean(spectral_centroids))
|
||||||
|
|
||||||
# RMS energy
|
# RMS energy
|
||||||
rms = librosa.feature.rms(y=y)[0]
|
rms_vals = _rms(y=y)[0]
|
||||||
avg_energy = float(np.mean(rms))
|
avg_energy = float(np.mean(rms_vals))
|
||||||
|
|
||||||
# Zero crossing rate
|
# Zero crossing rate
|
||||||
zcr = librosa.feature.zero_crossing_rate(y)[0]
|
zcr_vals = _zcr(y)[0]
|
||||||
avg_zcr = float(np.mean(zcr))
|
avg_zcr = float(np.mean(zcr_vals))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"bpm": round(bpm, 2),
|
"bpm": round(bpm, 2),
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""SonicForge audio_features - librosa-free DSP shim (numpy/scipy/soundfile only).
|
||||||
|
|
||||||
|
Thay the toan bo phan librosa duoc dung trong app bang cac ham nhe, cung
|
||||||
|
ngu nghia, khong keo theo numba/llvmlite (~171MB) + scikit-learn (~17MB).
|
||||||
|
|
||||||
|
Cac ham duoc clone theo ngu nghia cua librosa 0.11 tai cac call-site:
|
||||||
|
- load() ~ librosa.load (sr=None, mono=True)
|
||||||
|
- frames_to_time() ~ librosa.frames_to_time
|
||||||
|
- beat_track() ~ librosa.beat.beat_track (onset spectral flux
|
||||||
|
+ autocorrelation tempo + adaptive peak picking)
|
||||||
|
- spectral_centroid() ~ librosa.feature.spectral_centroid
|
||||||
|
- rms() ~ librosa.feature.rms
|
||||||
|
- zero_crossing_rate() ~ librosa.feature.zero_crossing_rate
|
||||||
|
- time_stretch() ~ librosa.effects.time_stretch (phase vocoder)
|
||||||
|
- pitch_shift() ~ librosa.effects.pitch_shift
|
||||||
|
- chroma_stft() ~ librosa.feature.chroma_cqt (xap xi STFT-based,
|
||||||
|
dung cho fingerprint/similarity, KHONG dung cho
|
||||||
|
hien thi pitch chinh xac)
|
||||||
|
|
||||||
|
Chi phu thuoc: numpy, scipy.signal, soundfile - tat ca da co trong bundle.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from scipy import signal as _signal
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"load", "frames_to_time", "beat_track",
|
||||||
|
"spectral_centroid", "rms", "zero_crossing_rate",
|
||||||
|
"time_stretch", "pitch_shift", "chroma_stft",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mat dinh giong librosa (hop_length=512, n_fft=2048, win_length=2048)
|
||||||
|
HOP_LENGTH = 512
|
||||||
|
N_FFT = 2048
|
||||||
|
WIN_LENGTH = 2048
|
||||||
|
|
||||||
|
|
||||||
|
# ── Load / time ──────────────────────────────────────────────────────────────
|
||||||
|
def load(path, sr=None, mono=True, offset=0.0, duration=None):
|
||||||
|
"""Doc audio giong librosa.load: float32 [-1,1], mono = mean cac channel.
|
||||||
|
|
||||||
|
sr=None -> giu nguyen sample rate goc (tat ca call-site deu dung sr=None).
|
||||||
|
Neu truyen sr -> resample bang scipy.signal.resample_poly.
|
||||||
|
"""
|
||||||
|
if offset or duration:
|
||||||
|
info = sf.info(path)
|
||||||
|
start = int(offset * info.samplerate) if offset else 0
|
||||||
|
n_frames = int(duration * info.samplerate) if duration else -1
|
||||||
|
data, file_sr = sf.read(path, dtype="float32", start=start, frames=n_frames)
|
||||||
|
else:
|
||||||
|
data, file_sr = sf.read(path, dtype="float32")
|
||||||
|
|
||||||
|
if data.ndim > 1:
|
||||||
|
if mono:
|
||||||
|
data = data.mean(axis=1)
|
||||||
|
else:
|
||||||
|
data = data.T # (channels, samples) giong librosa
|
||||||
|
|
||||||
|
if sr is not None and sr != file_sr:
|
||||||
|
from fractions import Fraction
|
||||||
|
ratio = Fraction(int(sr), int(file_sr))
|
||||||
|
up, down = ratio.numerator, ratio.denominator
|
||||||
|
data = _signal.resample_poly(data, up, down).astype(np.float32)
|
||||||
|
file_sr = sr
|
||||||
|
|
||||||
|
return data, file_sr
|
||||||
|
|
||||||
|
|
||||||
|
def frames_to_time(frames, sr=22050, hop_length=HOP_LENGTH, n_fft=None):
|
||||||
|
"""Chuyen frame index sang giay: frames * hop_length / sr (giong librosa)."""
|
||||||
|
return np.asanyarray(frames) * float(hop_length) / float(sr)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Framing / STFT (center=True, reflect pad, giong librosa) ────────────────
|
||||||
|
def _frame(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||||
|
"""Cua so hoa tin hieu voi center padding reflect (nhu librosa center=True)."""
|
||||||
|
pad = frame_length // 2
|
||||||
|
yp = np.pad(np.asarray(y, dtype=np.float64), pad, mode="reflect")
|
||||||
|
n_frames = 1 + (len(yp) - frame_length) // hop_length
|
||||||
|
if n_frames < 1:
|
||||||
|
n_frames = 1
|
||||||
|
idx = np.arange(frame_length)[:, None] + hop_length * np.arange(n_frames)[None, :]
|
||||||
|
return yp[idx]
|
||||||
|
|
||||||
|
|
||||||
|
def _stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH):
|
||||||
|
"""STFT mot phia (rfft) voi cua so hann periodic, reflect pad."""
|
||||||
|
y = np.asarray(y, dtype=np.float64)
|
||||||
|
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||||
|
f, _t, Zxx = _signal.stft(
|
||||||
|
y, fs=1.0, window=window, nperseg=win_length,
|
||||||
|
noverlap=win_length - hop_length, nfft=n_fft,
|
||||||
|
boundary="even", padded=True,
|
||||||
|
)
|
||||||
|
return Zxx
|
||||||
|
|
||||||
|
|
||||||
|
def _istft(Zxx, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH,
|
||||||
|
length=None):
|
||||||
|
"""ISTFT nguoc voi _stft (boi so chinh xac, rate=1 -> ~identity).
|
||||||
|
|
||||||
|
boundary=True: cat padding (nperseg//2 moi ben) nhu librosa center=True.
|
||||||
|
"""
|
||||||
|
window = _signal.get_window("hann", win_length, fftbins=False)
|
||||||
|
_t, y = _signal.istft(
|
||||||
|
Zxx, fs=1.0, window=window, nperseg=win_length,
|
||||||
|
noverlap=win_length - hop_length, nfft=n_fft,
|
||||||
|
input_onesided=True, boundary=True,
|
||||||
|
)
|
||||||
|
if length is not None and len(y) > length:
|
||||||
|
y = y[:length]
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
# ── Features ─────────────────────────────────────────────────────────────────
|
||||||
|
def spectral_centroid(y=None, sr=22050, n_fft=N_FFT, hop_length=HOP_LENGTH,
|
||||||
|
S=None):
|
||||||
|
"""Trong tam pho (brightness) - (1, n_frames) Hz, dung power spectrogram."""
|
||||||
|
if S is None:
|
||||||
|
S = np.abs(_stft(y, n_fft, hop_length)) ** 2
|
||||||
|
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||||
|
mag = np.abs(S)
|
||||||
|
denom = mag.sum(axis=0)
|
||||||
|
cent = np.divide(
|
||||||
|
np.sum(freqs[:, None] * mag, axis=0), denom,
|
||||||
|
out=np.zeros_like(denom), where=denom > 1e-10,
|
||||||
|
)
|
||||||
|
return cent[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def rms(y=None, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH, S=None):
|
||||||
|
"""RMS nang luong moi frame - (1, n_frames)."""
|
||||||
|
if S is not None:
|
||||||
|
frames = S # caller truyen power spectrogram
|
||||||
|
else:
|
||||||
|
frames = _frame(y, frame_length, hop_length)
|
||||||
|
return np.sqrt(np.mean(frames ** 2, axis=0))[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def zero_crossing_rate(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH):
|
||||||
|
"""Ti le zero-crossing moi frame - (1, n_frames)."""
|
||||||
|
frames = _frame(y, frame_length, hop_length)
|
||||||
|
signs = np.signbit(frames).astype(np.int8)
|
||||||
|
zcr = np.mean(np.abs(np.diff(signs, axis=0)), axis=0)
|
||||||
|
return zcr[None, :]
|
||||||
|
|
||||||
|
|
||||||
|
def chroma_stft(y=None, sr=22050, n_fft=4096, hop_length=HOP_LENGTH):
|
||||||
|
"""Chroma 12 pitch class (xap xi chroma_cqt bang STFT bin folding).
|
||||||
|
|
||||||
|
Tra ve (12, n_frames), chuan hoa L2 tung frame - tuong thich voi
|
||||||
|
cosine_similarity trong ai_dsp_engine.
|
||||||
|
"""
|
||||||
|
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||||
|
freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr)
|
||||||
|
# Chi giu bin <= 5kHz (tranh nhieu alias o high freq)
|
||||||
|
keep = freqs <= 5000.0
|
||||||
|
freqs = freqs[keep]
|
||||||
|
mag = mag[keep]
|
||||||
|
# note number -> pitch class
|
||||||
|
note = 12.0 * np.log2(np.maximum(freqs, 1e-6) / 440.0) + 69.0
|
||||||
|
pc = np.mod(np.round(note).astype(int), 12)
|
||||||
|
chroma = np.zeros((12, mag.shape[1]), dtype=np.float64)
|
||||||
|
np.add.at(chroma, pc, mag)
|
||||||
|
# L2 normalize tung frame (giong librosa)
|
||||||
|
norms = np.linalg.norm(chroma, axis=0)
|
||||||
|
chroma = np.divide(chroma, norms, out=np.zeros_like(chroma), where=norms > 1e-10)
|
||||||
|
return chroma
|
||||||
|
|
||||||
|
|
||||||
|
# ── Onset / tempo / beat (thay librosa.beat) ─────────────────────────────────
|
||||||
|
def _onset_strength(y, sr, hop_length=HOP_LENGTH, n_fft=N_FFT):
|
||||||
|
"""Onset envelope: spectral flux (log-magnitude diff, chi chieu duong)."""
|
||||||
|
mag = np.abs(_stft(y, n_fft, hop_length))
|
||||||
|
logmag = np.log1p(1000.0 * mag)
|
||||||
|
flux = np.diff(logmag, axis=1)
|
||||||
|
onset = np.maximum(flux, 0.0).sum(axis=0)
|
||||||
|
if onset.size == 0:
|
||||||
|
return onset
|
||||||
|
# Tru moving-average ~1s de loai trend (giong librosa detrend)
|
||||||
|
win = max(1, int(round(1.0 * sr / hop_length)))
|
||||||
|
if len(onset) >= win:
|
||||||
|
kernel = np.ones(win) / win
|
||||||
|
ma = np.convolve(onset, kernel, mode="same")
|
||||||
|
onset = np.maximum(onset - ma, 0.0)
|
||||||
|
return onset
|
||||||
|
|
||||||
|
|
||||||
|
def _autocorr(x):
|
||||||
|
"""Autocorrelation chuan hoa (FFT, O(n log n)), r[0]=1."""
|
||||||
|
n = len(x)
|
||||||
|
if n < 2:
|
||||||
|
return np.ones(n)
|
||||||
|
x = x - x.mean()
|
||||||
|
nfft = 2 ** int(np.ceil(np.log2(2 * n)))
|
||||||
|
X = np.fft.rfft(x, nfft)
|
||||||
|
r = np.fft.irfft(X * np.conj(X), nfft)[:n]
|
||||||
|
denom = np.maximum(n - np.arange(n), 1)
|
||||||
|
r = r / denom
|
||||||
|
r0 = r[0] if r[0] != 0 else 1.0
|
||||||
|
return r / r0
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tempo(onset, sr, hop_length=HOP_LENGTH, bpm_range=(30.0, 300.0),
|
||||||
|
start_bpm=120.0):
|
||||||
|
"""Uoc luong BPM bang autocorrelation cua onset envelope.
|
||||||
|
|
||||||
|
Co them prior Gaussian quanh start_bpm (mac dinh 120, nhu librosa) de
|
||||||
|
chon dung octave (tranh roi vao nua/double tempo khi autocorrelation
|
||||||
|
bi mo ho giua cac harmonic).
|
||||||
|
"""
|
||||||
|
if len(onset) < 4:
|
||||||
|
return float(start_bpm)
|
||||||
|
min_lag = int(np.ceil(60.0 * sr / (bpm_range[1] * hop_length)))
|
||||||
|
max_lag = int(np.floor(60.0 * sr / (bpm_range[0] * hop_length)))
|
||||||
|
if max_lag <= min_lag or max_lag >= len(onset):
|
||||||
|
return float(start_bpm)
|
||||||
|
ac = _autocorr(onset)
|
||||||
|
lags = np.arange(min_lag, max_lag + 1)
|
||||||
|
tempi = 60.0 * sr / (hop_length * lags)
|
||||||
|
# prior rong ~0.7 octave quanh start_bpm (log2 scale)
|
||||||
|
prior = np.exp(-0.5 * ((np.log2(np.maximum(tempi, 1.0)) - np.log2(start_bpm)) / 0.7) ** 2)
|
||||||
|
seg = ac[lags] * prior
|
||||||
|
best = lags[int(np.argmax(seg))]
|
||||||
|
tempo = 60.0 * sr / (hop_length * best)
|
||||||
|
# Neu tempo > 200 -> kha nang la harmonic (half-time) -> chia doi
|
||||||
|
if tempo > 200.0 and best * 2 <= max_lag:
|
||||||
|
tempo = 60.0 * sr / (hop_length * best * 2)
|
||||||
|
return float(tempo)
|
||||||
|
|
||||||
|
|
||||||
|
def _localmax(x):
|
||||||
|
"""Boolean mask cac diem cuc dai dia phuong (lon hon 2 lan can)."""
|
||||||
|
n = len(x)
|
||||||
|
if n < 3:
|
||||||
|
return np.zeros(n, dtype=bool)
|
||||||
|
out = np.zeros(n, dtype=bool)
|
||||||
|
out[1:-1] = (x[1:-1] > x[:-2]) & (x[1:-1] >= x[2:])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _beat_frames(onset, sr, hop_length=HOP_LENGTH, tempo=120.0):
|
||||||
|
"""Chon beat frames bang peak-picking thich nghi + rang buoc tempo grid."""
|
||||||
|
n = len(onset)
|
||||||
|
if n == 0:
|
||||||
|
return np.array([], dtype=int)
|
||||||
|
period = 60.0 * sr / (hop_length * max(tempo, 1.0)) # frames/beat
|
||||||
|
win = max(1, int(round(period)))
|
||||||
|
kernel = np.ones(win) / win
|
||||||
|
ma = np.convolve(onset, kernel, mode="same")
|
||||||
|
thresh = 1.25 * ma + 1e-9
|
||||||
|
|
||||||
|
cand = np.where(_localmax(onset) & (onset >= thresh))[0]
|
||||||
|
if cand.size == 0:
|
||||||
|
cand = np.where(_localmax(onset))[0]
|
||||||
|
if cand.size == 0:
|
||||||
|
cand = np.arange(0, n, max(1, int(round(period))))
|
||||||
|
|
||||||
|
beats = [int(cand[0])]
|
||||||
|
while True:
|
||||||
|
expected = beats[-1] + period
|
||||||
|
if expected >= n:
|
||||||
|
break
|
||||||
|
lo, hi = expected - 0.45 * period, expected + 0.45 * period
|
||||||
|
in_win = cand[(cand >= lo) & (cand <= hi)]
|
||||||
|
if in_win.size == 0:
|
||||||
|
nxt = int(round(expected))
|
||||||
|
if nxt >= n:
|
||||||
|
break
|
||||||
|
beats.append(nxt)
|
||||||
|
else:
|
||||||
|
beats.append(int(in_win[np.argmin(np.abs(in_win - expected))]))
|
||||||
|
# Chong beat kep (khoang cach < 0.5 period)
|
||||||
|
if len(beats) >= 2 and beats[-1] - beats[-2] < 0.5 * period:
|
||||||
|
beats.pop()
|
||||||
|
continue
|
||||||
|
if len(beats) > 2000:
|
||||||
|
break
|
||||||
|
return np.array(beats, dtype=int)
|
||||||
|
|
||||||
|
|
||||||
|
def beat_track(y=None, sr=22050, hop_length=HOP_LENGTH, start_bpm=120.0,
|
||||||
|
tightness=100):
|
||||||
|
"""Beat tracking don gian: (tempo: float, beat_frames: np.ndarray int).
|
||||||
|
|
||||||
|
Tempo bang autocorrelation onset; beats bang peak-picking thich nghi.
|
||||||
|
Tuong thich kieu tra ve cua librosa.beat.beat_track tai call-site
|
||||||
|
(analyzer xu ly ca scalar lan ndarray).
|
||||||
|
"""
|
||||||
|
onset = _onset_strength(y, sr, hop_length)
|
||||||
|
tempo = _estimate_tempo(onset, sr, hop_length, start_bpm=start_bpm)
|
||||||
|
beats = _beat_frames(onset, sr, hop_length, tempo)
|
||||||
|
return tempo, beats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Effects (thay librosa.effects) ───────────────────────────────────────────
|
||||||
|
def _phase_vocoder(D, rate, hop_length=HOP_LENGTH):
|
||||||
|
"""Phase vocoder time-stretch kinh dien (DAFX/Puckette).
|
||||||
|
|
||||||
|
D: STFT (freq_bins, n_frames). rate > 1 -> nhanh hon (ngan hon).
|
||||||
|
Tra ve STFT da stretch voi so frame ~ n_frames / rate.
|
||||||
|
"""
|
||||||
|
n_freq, n_frames = D.shape
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError("rate phai > 0")
|
||||||
|
if rate == 1.0:
|
||||||
|
return D
|
||||||
|
time_steps = np.arange(0, n_frames, rate, dtype=float)
|
||||||
|
n_out = len(time_steps)
|
||||||
|
if n_out == 0:
|
||||||
|
return D[:, :0]
|
||||||
|
out = np.zeros((n_freq, n_out), dtype=np.complex128)
|
||||||
|
# Phase advance moi hop cua tung bin tan so
|
||||||
|
phase_adv = np.linspace(0.0, np.pi * hop_length, n_freq)
|
||||||
|
mag = np.abs(D)
|
||||||
|
phase_acc = np.angle(D[:, 0])
|
||||||
|
for t, step in enumerate(time_steps):
|
||||||
|
idx = int(step)
|
||||||
|
if idx >= n_frames:
|
||||||
|
break
|
||||||
|
if idx + 1 >= n_frames:
|
||||||
|
out[:, t] = mag[:, idx] * np.exp(1j * phase_acc)
|
||||||
|
break
|
||||||
|
# Phase difference that giua 2 frame lien tiep (true frequency)
|
||||||
|
dphase = np.angle(D[:, idx + 1]) - np.angle(D[:, idx]) - phase_adv
|
||||||
|
dphase -= 2.0 * np.pi * np.round(dphase / (2.0 * np.pi))
|
||||||
|
phase_acc = phase_acc + phase_adv + dphase
|
||||||
|
out[:, t] = 0.5 * (mag[:, idx] + mag[:, idx + 1]) * np.exp(1j * phase_acc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def time_stretch(y, rate, **kwargs):
|
||||||
|
"""Time stretch giu nguyen pitch. rate > 1 -> nhanh/ngan hon."""
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError("rate phai > 0")
|
||||||
|
if rate == 1.0:
|
||||||
|
return np.asarray(y, dtype=np.float32)
|
||||||
|
y = np.asarray(y, dtype=np.float64)
|
||||||
|
D = _stft(y)
|
||||||
|
D_stretch = _phase_vocoder(D, rate)
|
||||||
|
y_out = _istft(D_stretch)
|
||||||
|
# Cat ve dung do dai ky vong: len(y) / rate
|
||||||
|
target = int(round(len(y) / rate))
|
||||||
|
if len(y_out) > target:
|
||||||
|
y_out = y_out[:target]
|
||||||
|
return y_out.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_shift(y, sr=22050, n_steps=1, **kwargs):
|
||||||
|
"""Dich pitch n semitone (positive = cao hon), giu nguyen duration.
|
||||||
|
|
||||||
|
Co che (giong librosa): time_stretch voi rate=2^(-n/12) roi resample
|
||||||
|
nguoc lai ve dung do dai goc -> pitch doi, duration giu nguyen.
|
||||||
|
"""
|
||||||
|
if n_steps == 0:
|
||||||
|
return np.asarray(y, dtype=np.float32)
|
||||||
|
rate = 2.0 ** (-float(n_steps) / 12.0)
|
||||||
|
y_shift = time_stretch(y, rate)
|
||||||
|
# Resample (FFT) ve dung do dai goc: factor = rate
|
||||||
|
target = int(round(len(y_shift) * rate))
|
||||||
|
if target != len(y_shift) and target > 0:
|
||||||
|
y_shift = _signal.resample(y_shift, target)
|
||||||
|
return np.asarray(y_shift, dtype=np.float32)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import librosa
|
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
|
from app.core.audio_features import load as _load
|
||||||
|
|
||||||
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
|
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
|
||||||
"""
|
"""
|
||||||
@@ -57,7 +57,7 @@ def find_nearest_zero_crossing_file(file_path: str, target_time_sec: float, sear
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Load mono audio for zero crossing analysis
|
# Load mono audio for zero crossing analysis
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error finding zero crossing: {e}")
|
print(f"Error finding zero crossing: {e}")
|
||||||
@@ -136,7 +136,7 @@ def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
|
|||||||
dict: {"peaks": [...], "duration": float, "sample_rate": int}
|
dict: {"peaks": [...], "duration": float, "sample_rate": int}
|
||||||
"""
|
"""
|
||||||
# Load mono audio
|
# Load mono audio
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
|
|
||||||
total_samples = len(y)
|
total_samples = len(y)
|
||||||
duration = float(total_samples) / sr
|
duration = float(total_samples) / sr
|
||||||
@@ -181,7 +181,7 @@ def generate_rms_waveform(file_path: str, num_points: int = 800) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: {"rms": [...], "duration": float, "sample_rate": int}
|
dict: {"rms": [...], "duration": float, "sample_rate": int}
|
||||||
"""
|
"""
|
||||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
y, sr = _load(file_path, sr=None, mono=True)
|
||||||
|
|
||||||
total_samples = len(y)
|
total_samples = len(y)
|
||||||
duration = float(total_samples) / sr
|
duration = float(total_samples) / sr
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import os, logging, math
|
import os, logging, math
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import scipy.signal as signal
|
# scipy.signal import LAZY (chi dung trong ham) — giam thoi gian khoi dong
|
||||||
|
# engine (khong nap scipy+OpenBLAS ~70MB luc boot)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.vst_engine import (
|
from app.core.vst_engine import (
|
||||||
render_midi_events_to_audio,
|
render_midi_events_to_audio,
|
||||||
@@ -385,7 +386,8 @@ class PythonRenderEngine:
|
|||||||
for ch in range(2):
|
for ch in range(2):
|
||||||
ir = ir_l if ch == 0 else ir_r
|
ir = ir_l if ch == 0 else ir_r
|
||||||
# Convolve
|
# Convolve
|
||||||
conv = signal.convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
from scipy.signal import convolve
|
||||||
|
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
||||||
wet[ch, :] = conv
|
wet[ch, :] = conv
|
||||||
track_buffer = dry + wet * 0.4
|
track_buffer = dry + wet * 0.4
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import scipy.signal as signal
|
import scipy.signal as signal
|
||||||
import librosa
|
from app.core.audio_features import time_stretch as _time_stretch, pitch_shift as _pitch_shift
|
||||||
|
|
||||||
class SubTabDSPEngine:
|
class SubTabDSPEngine:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -12,7 +12,7 @@ class SubTabDSPEngine:
|
|||||||
return y
|
return y
|
||||||
|
|
||||||
if preserve_pitch:
|
if preserve_pitch:
|
||||||
return librosa.effects.time_stretch(y, rate=speed_ratio)
|
return _time_stretch(y, rate=speed_ratio)
|
||||||
else:
|
else:
|
||||||
num_samples_new = int(len(y) / speed_ratio)
|
num_samples_new = int(len(y) / speed_ratio)
|
||||||
return signal.resample(y, num_samples_new)
|
return signal.resample(y, num_samples_new)
|
||||||
@@ -80,7 +80,7 @@ class SubTabDSPEngine:
|
|||||||
"""
|
"""
|
||||||
if n_steps == 0:
|
if n_steps == 0:
|
||||||
return y
|
return y
|
||||||
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
|
return _pitch_shift(y, sr=sr, n_steps=n_steps)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def merge_back_to_parent(
|
def merge_back_to_parent(
|
||||||
|
|||||||
+95
-12
@@ -3,7 +3,6 @@ import uuid
|
|||||||
import time
|
import time
|
||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
from celery import Celery
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
||||||
from app.core.audio_editor import (
|
from app.core.audio_editor import (
|
||||||
@@ -13,6 +12,21 @@ from app.core.dsp_utils import find_nearest_zero_crossing_file
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Task layer 2 che do:
|
||||||
|
# - Server/Docker: celery day du (broker Redis) — dung nhu cu.
|
||||||
|
# - Desktop slim (PyInstaller KHONG bundle celery/redis): task chay in-process
|
||||||
|
# (thread nen + registry dict), API contract GIONG het (.delay() tra
|
||||||
|
# task_id, /tasks/{id} tra status/result) nen frontend khong doi gi.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from celery import Celery
|
||||||
|
HAS_CELERY = True
|
||||||
|
except Exception: # pragma: no cover - frozen desktop slim build
|
||||||
|
Celery = None
|
||||||
|
HAS_CELERY = False
|
||||||
|
|
||||||
|
if HAS_CELERY:
|
||||||
celery_app = Celery(
|
celery_app = Celery(
|
||||||
"audio_tasks",
|
"audio_tasks",
|
||||||
broker=settings.CELERY_BROKER_URL,
|
broker=settings.CELERY_BROKER_URL,
|
||||||
@@ -28,7 +42,7 @@ celery_app.conf.update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
|
# Che do desktop (SF_DESKTOP=1, do desktop_engine.py set): chay task dong bo
|
||||||
# trong tien trinh (eager) — ban Standalone Windows KHONG kem Redis broker.
|
# trong tien trinh (eager) — ban Standalone KHONG kem Redis broker.
|
||||||
if os.getenv("SF_DESKTOP") == "1":
|
if os.getenv("SF_DESKTOP") == "1":
|
||||||
celery_app.conf.update(
|
celery_app.conf.update(
|
||||||
task_always_eager=True,
|
task_always_eager=True,
|
||||||
@@ -37,16 +51,85 @@ if os.getenv("SF_DESKTOP") == "1":
|
|||||||
result_backend="cache+memory://",
|
result_backend="cache+memory://",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
|
# ── Lich trinh tu dong don dep file het han (Week 5) ──
|
||||||
celery_app.conf.beat_schedule = {
|
celery_app.conf.beat_schedule = {
|
||||||
"cleanup-expired-files-every-hour": {
|
"cleanup-expired-files-every-hour": {
|
||||||
"task": "app.tasks.worker.cleanup_expired_files_task",
|
"task": "app.tasks.worker.cleanup_expired_files_task",
|
||||||
"schedule": 3600.0, # Chạy mỗi giờ
|
"schedule": 3600.0, # Chay moi gio
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
celery_app = None
|
||||||
|
# Registry in-process cho desktop slim: task_id -> {"status", "result"/"error"}
|
||||||
|
_results = {}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
def _task(fn):
|
||||||
|
"""Wrapper: celery task (server) hoac in-process task (desktop slim)."""
|
||||||
|
if HAS_CELERY:
|
||||||
|
return celery_app.task(fn)
|
||||||
|
return _InProcessTask(fn)
|
||||||
|
|
||||||
|
|
||||||
|
class _InProcessTask:
|
||||||
|
"""Task chay tren thread nen, ket qua luu vao registry dict — dung cho
|
||||||
|
bundle desktop khong kem celery (tiet kiem ~40MB)."""
|
||||||
|
|
||||||
|
def __init__(self, fn):
|
||||||
|
self._fn = fn
|
||||||
|
|
||||||
|
def delay(self, *args, **kwargs):
|
||||||
|
import threading
|
||||||
|
tid = uuid.uuid4().hex
|
||||||
|
_results[tid] = {"status": "PENDING"}
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
result = self._fn(*args, **kwargs)
|
||||||
|
_results[tid] = {"status": "SUCCESS", "result": result}
|
||||||
|
except Exception as e: # noqa: BLE001 - bao loi day du cho UI
|
||||||
|
logger.exception("In-process task %s failed", tid)
|
||||||
|
_results[tid] = {"status": "FAILURE", "error": str(e)}
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name=f"task-{tid[:8]}").start()
|
||||||
|
return _SimpleAsyncResult(tid)
|
||||||
|
|
||||||
|
|
||||||
|
class _SimpleAsyncResult:
|
||||||
|
"""Giong celery.result.AsyncResult ve mat API cho desktop slim."""
|
||||||
|
|
||||||
|
def __init__(self, task_id):
|
||||||
|
self.task_id = task_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
"""Giong celery.result.AsyncResult.id — audio.py dung task.id."""
|
||||||
|
return self.task_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self):
|
||||||
|
return _results.get(self.task_id, {}).get("status", "PENDING")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def result(self):
|
||||||
|
return _results.get(self.task_id, {}).get("result")
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
return _results.get(self.task_id, {}).get("status") in ("SUCCESS", "FAILURE")
|
||||||
|
|
||||||
|
def successful(self):
|
||||||
|
return self.status == "SUCCESS"
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_result(task_id):
|
||||||
|
"""Tra AsyncResult (celery) hoac _SimpleAsyncResult (desktop slim)."""
|
||||||
|
if HAS_CELERY:
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
return AsyncResult(task_id, app=celery_app)
|
||||||
|
return _SimpleAsyncResult(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@_task
|
||||||
def analyze_audio_task(file_id: str):
|
def analyze_audio_task(file_id: str):
|
||||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
@@ -54,7 +137,7 @@ def analyze_audio_task(file_id: str):
|
|||||||
return analyze_audio(file_path)
|
return analyze_audio(file_path)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
||||||
model: str = "deepseek-chat"):
|
model: str = "deepseek-chat"):
|
||||||
"""
|
"""
|
||||||
@@ -78,7 +161,7 @@ def analyze_ai_task(file_id: str, api_base_url: str = None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def edit_audio_task(config: dict):
|
def edit_audio_task(config: dict):
|
||||||
file_id = config.get("file_id")
|
file_id = config.get("file_id")
|
||||||
|
|
||||||
@@ -98,7 +181,7 @@ def edit_audio_task(config: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def export_audio_task(file_id: str, format: str = "wav",
|
def export_audio_task(file_id: str, format: str = "wav",
|
||||||
sample_rate: int = 44100, bit_depth: int = 16):
|
sample_rate: int = 44100, bit_depth: int = 16):
|
||||||
"""
|
"""
|
||||||
@@ -131,7 +214,7 @@ def export_audio_task(file_id: str, format: str = "wav",
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def mix_multitrack_task(session_config: dict):
|
def mix_multitrack_task(session_config: dict):
|
||||||
"""
|
"""
|
||||||
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||||
@@ -184,7 +267,7 @@ def mix_multitrack_task(session_config: dict):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def process_multitrack_session_task(session_config: dict):
|
def process_multitrack_session_task(session_config: dict):
|
||||||
"""
|
"""
|
||||||
Task xử lý toàn bộ session với nhiều tracks và clips.
|
Task xử lý toàn bộ session với nhiều tracks và clips.
|
||||||
@@ -280,7 +363,7 @@ def process_multitrack_session_task(session_config: dict):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def cleanup_expired_files_task(max_age_hours: int = 24):
|
def cleanup_expired_files_task(max_age_hours: int = 24):
|
||||||
"""
|
"""
|
||||||
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
|
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
|
||||||
@@ -315,7 +398,7 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
@_task
|
||||||
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
|
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
|
||||||
"""
|
"""
|
||||||
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
|
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
|
||||||
|
|||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build_linux.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (deb + AppImage)
|
||||||
|
# Chay tren Linux: bash build_linux.sh
|
||||||
|
# Yeu cau: python3, pip, node/npm, rust/cargo, webkit2gtk-4.1, libappindicator,
|
||||||
|
# librsvg (xem README / DISTRIBUTION_PLAN.md)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "== [1/6] Python dependencies =="
|
||||||
|
# PyInstaller tren Linux can objdump (binutils). May build that phai co:
|
||||||
|
# sudo apt-get install -y binutils
|
||||||
|
python3 -m pip install --upgrade pip >/dev/null
|
||||||
|
python3 -m pip install -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
echo "== [2/6] Frontend bundle (app.jsx -> app.precompiled.js) =="
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
if [ ! -d "node_modules/@babel/standalone" ]; then
|
||||||
|
echo "Thieu @babel/standalone - dang cai them..."
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
|
||||||
|
python3 -m PyInstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
echo "== [3.5/6] Verify bundle contents (app/static, app/templates phai co) =="
|
||||||
|
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
|
||||||
|
|
||||||
|
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
|
||||||
|
if [ ! -f "dist/daw_engine/daw_engine" ]; then
|
||||||
|
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf src-tauri/resources/daw_engine
|
||||||
|
mkdir -p src-tauri/resources/daw_engine
|
||||||
|
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
|
||||||
|
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
|
||||||
|
|
||||||
|
echo "== [5/6] Kiem tra resources truoc khi tauri build =="
|
||||||
|
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
|
||||||
|
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== [6/6] Tauri build (deb + AppImage) =="
|
||||||
|
npm install -D @tauri-apps/cli --no-audit --no-fund
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== DONE =="
|
||||||
|
echo " deb : src-tauri/target/release/bundle/deb/sonicforge-daw_1.0.0_amd64.deb"
|
||||||
|
echo " AppImage: src-tauri/target/release/bundle/appimage/SonicForgeDAW_1.0.0_amd64.AppImage"
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build_macos.sh - build daw_engine (PyInstaller ONEDIR) + Tauri v2 (.app + .dmg)
|
||||||
|
# Chay tren macOS: bash build_macos.sh
|
||||||
|
# LUU Y: macOS yeu cau codesign + notarize truoc khi phat hanh ra ngoai
|
||||||
|
# (Gatekeeper). Xem DISTRIBUTION_PLAN.md.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "== [1/6] Python dependencies =="
|
||||||
|
python3 -m pip install --upgrade pip >/dev/null
|
||||||
|
python3 -m pip install -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
echo "== [2/6] Frontend bundle =="
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
if [ ! -d "node_modules/@babel/standalone" ]; then
|
||||||
|
npm install @babel/standalone --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
node build.mjs
|
||||||
|
|
||||||
|
echo "== [3/6] Build daw_engine (PyInstaller ONEDIR) =="
|
||||||
|
python3 -m PyInstaller engine.spec --clean --noconfirm
|
||||||
|
|
||||||
|
echo "== [3.5/6] Verify bundle contents =="
|
||||||
|
python3 tools/verify_bundle.py || { echo "ERROR: Bundle thieu asset - dung build!"; exit 1; }
|
||||||
|
|
||||||
|
echo "== [4/6] Copy onedir engine -> src-tauri/resources/daw_engine =="
|
||||||
|
if [ ! -f "dist/daw_engine/daw_engine" ]; then
|
||||||
|
echo "ERROR: dist/daw_engine/daw_engine khong ton tai (onedir build loi?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf src-tauri/resources/daw_engine
|
||||||
|
mkdir -p src-tauri/resources/daw_engine
|
||||||
|
cp -a dist/daw_engine/. src-tauri/resources/daw_engine/
|
||||||
|
echo "Copied onedir engine -> src-tauri/resources/daw_engine"
|
||||||
|
|
||||||
|
echo "== [5/6] Kiem tra resources =="
|
||||||
|
if [ ! -f "src-tauri/resources/daw_engine/daw_engine" ] || [ ! -d "src-tauri/resources/daw_engine/_internal" ]; then
|
||||||
|
echo "ERROR: thieu src-tauri/resources/daw_engine/{daw_engine,_internal}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== [6/6] Tauri build (dmg) =="
|
||||||
|
npm install -D @tauri-apps/cli --no-audit --no-fund
|
||||||
|
npx tauri build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "== DONE =="
|
||||||
|
echo " dmg: src-tauri/target/release/bundle/dmg/SonicForgeDAW_1.0.0_x64.dmg"
|
||||||
|
echo " (Codesign/notarize: codesign --deep -s \"Developer ID Application: ...\" "
|
||||||
|
echo " src-tauri/target/release/bundle/macos/SonicForgeDAW.app ; xcrun notarytool submit ...)"
|
||||||
+70
-49
@@ -1,11 +1,11 @@
|
|||||||
# engine.spec — PyInstaller config cho daw_engine.exe (sidecar Python)
|
# engine.spec — PyInstaller config cho daw_engine (sidecar Python)
|
||||||
# Chay: pyinstaller engine.spec --clean --noconfirm (tren Windows)
|
# Chay: pyinstaller engine.spec --clean --noconfirm (Windows/Linux/macOS)
|
||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules, collect_data_files
|
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files
|
||||||
|
|
||||||
# Native DLL cho pedalboard va soundfile
|
# Native DLL cho pedalboard va soundfile (Windows .pyd/.dll, Linux .so)
|
||||||
binaries = collect_dynamic_libs('pedalboard')
|
binaries = collect_dynamic_libs('pedalboard')
|
||||||
binaries += collect_dynamic_libs('soundfile')
|
binaries += collect_dynamic_libs('soundfile')
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ binaries += collect_dynamic_libs('soundfile')
|
|||||||
# duoc bundle qua collect_data_files('app').
|
# duoc bundle qua collect_data_files('app').
|
||||||
_SPEC_ROOT = os.path.abspath(SPECPATH)
|
_SPEC_ROOT = os.path.abspath(SPECPATH)
|
||||||
|
|
||||||
# ⚠️ GOC ROOT CUA MOI LOI 'app\static does not exist' (gap 3 lan):
|
# ⚠️ GOC ROOT CUA MOI LOI 'app\\static does not exist' (gap 3 lan):
|
||||||
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
|
# lenh `pyinstaller engine.spec` (entry-point script) KHONG them CWD vao
|
||||||
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
|
# sys.path (chi `python -m PyInstaller` moi them). collect_data_files('app')
|
||||||
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
|
# import package qua sys.path -> khong thay 'app' -> tra ve [] AM THAM ->
|
||||||
@@ -25,8 +25,8 @@ if _SPEC_ROOT not in _sys.path:
|
|||||||
_sys.path.insert(0, _SPEC_ROOT)
|
_sys.path.insert(0, _SPEC_ROOT)
|
||||||
|
|
||||||
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
|
# Assets cua app: bundle QUA IMPORT SYSTEM (collect_data_files) — an toan nhat.
|
||||||
# Loai tru storage (57MB soundfonts/uploads — vo ich trong onefile, config.py
|
# Loai tru storage (57MB soundfonts/uploads — vo ich, config.py da chuyen
|
||||||
# da chuyen storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
|
# storage sang %APPDATA%\\SonicForgeDAW khi frozen) va __pycache__.
|
||||||
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
|
datas = collect_data_files('app', excludes=['**/storage/**', '**/__pycache__/**', '**/*.pyc'])
|
||||||
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
|
# Fallback cuoi cung: neu collect_data_files van tra ve rong (phong moi truong
|
||||||
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
|
# hop ky la), dung datas TINH absolute — tinh huong xau nhat van co du assets.
|
||||||
@@ -41,49 +41,46 @@ datas += [
|
|||||||
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
|
(os.path.join(_SPEC_ROOT, 'md'), 'md'), # /ai-prompt-generator (doc, ngoai package app)
|
||||||
]
|
]
|
||||||
|
|
||||||
# librosa 0.11 dùng lazy_loader.attach_stub -> lúc RUNTIME cần file .pyi
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
# ton tai tren disk ('Cannot load imports from non-existent stub ...librosa\__init__.pyi').
|
# TOI UU BUNDLE (SonicForgeStudio 1.1): 409MB -> ~120MB
|
||||||
# PyInstaller mac dinh KHONG bundle .pyi -> phai collect explicit.
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
datas += collect_data_files('librosa', includes=['**/*.pyi'])
|
# 1. librosa/numba/llvmlite (~171MB) + scikit-learn (~17MB) da DUOC LOAI BO
|
||||||
|
# khoi code (app/core/audio_features.py thay the, numpy/scipy/soundfile).
|
||||||
# scipy >= 1.18 tach scipy.stats thanh nhieu module con (vd
|
# 2. celery/kombu/billiard/redis (~40MB) KHONG bundle — desktop chay task
|
||||||
# _ansari_swilk_statistics) import lazy ben trong ham -> hook scipy cua
|
# eager dong bo, khong can broker (app/api/v1/tasks.py da lazy + fallback).
|
||||||
# PyInstaller miss -> ModuleNotFoundError luc runtime. Giai phap TRIET DE:
|
# 3. scipy: KHONG con quet toan bo site-packages/scipy (truoc day bundle ca
|
||||||
# scan FILESYSTEM toan bo site-packages/scipy (khong import, khong walk —
|
# scipy.stats/sparse/optimize/linalg ~48MB). Chi quet scipy.signal — goi
|
||||||
# pkgutil.walk_packages BO QUA AM THAM subpackage import loi luc build,
|
# lazy-import noi bo cua no van duoc bat day du (scipy.signal.windows,
|
||||||
# da gap: may user mat ca cay scipy.sparse.csgraph._shortest_path).
|
# _savitzky_golay, _spectral_py... duoc import bang ten ben trong ham).
|
||||||
# Bat moi module .py + C-extension .pyd/.so -> hiddenimports day du.
|
# scipy.signal la goi DUY NHAT con duoc app dung (sub_tab_dsp,
|
||||||
|
# render_engine, audio_features).
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
import importlib.util as _ilu
|
import importlib.util as _ilu
|
||||||
import glob as _glob
|
import glob as _glob
|
||||||
_scipy_spec = _ilu.find_spec('scipy')
|
|
||||||
_scipy_dir = os.path.dirname(os.path.abspath(_scipy_spec.origin))
|
|
||||||
_scipy_hidden = []
|
|
||||||
for _ext in ('*.py', '*.pyd', '*.so'):
|
|
||||||
for _f in _glob.glob(os.path.join(_scipy_dir, '**', _ext), recursive=True):
|
|
||||||
_rel = os.path.relpath(_f, _scipy_dir)
|
|
||||||
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
|
|
||||||
_pkg = os.path.dirname(_rel).replace(os.sep, '.')
|
|
||||||
_mod = ('scipy.' + _pkg + '.' + _base) if _pkg else ('scipy.' + _base)
|
|
||||||
if _mod not in _scipy_hidden:
|
|
||||||
_scipy_hidden.append(_mod)
|
|
||||||
# Cung co bang hiddenimport TINH: _morestats import module nay o top-level
|
|
||||||
# (scipy 1.18+); collect_submodules du phong nhung neu miss (version khac
|
|
||||||
# tren may user) thi dong nay van dam bao bundle co.
|
|
||||||
if 'scipy.stats._ansari_swilk_statistics' not in _scipy_hidden:
|
|
||||||
_scipy_hidden.append('scipy.stats._ansari_swilk_statistics')
|
|
||||||
# scipy.sparse.csgraph cung lazy-import C-extension tu ben trong ham (vd
|
|
||||||
# _shortest_path, _traversal, _matching) — hiddenimport tinh phong walk miss.
|
|
||||||
for _m in ('scipy.sparse.csgraph._shortest_path', 'scipy.sparse.csgraph._traversal',
|
|
||||||
'scipy.sparse.csgraph._matching', 'scipy.sparse.csgraph._min_spanning_tree'):
|
|
||||||
if _m not in _scipy_hidden:
|
|
||||||
_scipy_hidden.append(_m)
|
|
||||||
|
|
||||||
a = Analysis(
|
def _scan_pkg_modules(pkg_name: str):
|
||||||
['desktop_engine.py'],
|
"""Scan filesystem cua 1 package con (khong import, khong walk) ->
|
||||||
pathex=[_SPEC_ROOT],
|
bat moi module .py/.pyd/.so -> hiddenimports day du, tranh lazy-import miss."""
|
||||||
binaries=binaries,
|
_spec = _ilu.find_spec(pkg_name)
|
||||||
datas=datas,
|
if _spec is None or _spec.origin is None:
|
||||||
hiddenimports=collect_submodules('celery.fixups') + _scipy_hidden + [
|
print(f"WARN: khong tim thay package '{pkg_name}' - bo qua scan")
|
||||||
|
return []
|
||||||
|
_pkg_dir = os.path.dirname(os.path.abspath(_spec.origin))
|
||||||
|
_out = []
|
||||||
|
for _ext in ('*.py', '*.pyd', '*.so'):
|
||||||
|
for _f in _glob.glob(os.path.join(_pkg_dir, '**', _ext), recursive=True):
|
||||||
|
_rel = os.path.relpath(_f, _pkg_dir)
|
||||||
|
_base = os.path.basename(_rel).split('.')[0] # bo .cpython-312-x86_64... .so
|
||||||
|
_sub = os.path.dirname(_rel).replace(os.sep, '.')
|
||||||
|
_mod = (pkg_name + '.' + _sub + '.' + _base) if _sub else (pkg_name + '.' + _base)
|
||||||
|
if _mod not in _out:
|
||||||
|
_out.append(_mod)
|
||||||
|
return _out
|
||||||
|
|
||||||
|
_scipy_signal_hidden = _scan_pkg_modules('scipy.signal')
|
||||||
|
|
||||||
|
# Uvicorn lazy-load loop/protocol theo ten (string) -> hiddenimport tinh.
|
||||||
|
_hidden = [
|
||||||
'uvicorn.logging',
|
'uvicorn.logging',
|
||||||
'uvicorn.loops',
|
'uvicorn.loops',
|
||||||
'uvicorn.loops.auto',
|
'uvicorn.loops.auto',
|
||||||
@@ -96,11 +93,35 @@ a = Analysis(
|
|||||||
'soundfile',
|
'soundfile',
|
||||||
'sf2utils',
|
'sf2utils',
|
||||||
'mido.backends.rtmidi',
|
'mido.backends.rtmidi',
|
||||||
],
|
] + _scipy_signal_hidden
|
||||||
|
|
||||||
|
# Khoa khong bundle: loai toan bo cay nang khong con duoc dung.
|
||||||
|
_excludes = [
|
||||||
|
'tkinter',
|
||||||
|
# libs da thay the (audio_features.py)
|
||||||
|
'librosa', 'numba', 'llvmlite', 'sklearn', 'scikit-learn',
|
||||||
|
'joblib', 'threadpoolctl', 'audioread', 'lazy_loader', 'soxr',
|
||||||
|
# celery/redis chi dung cho server (Docker), khong cho desktop
|
||||||
|
'celery', 'kombu', 'billiard', 'vine', 'amqp', 'redis',
|
||||||
|
'click_didyoumean', 'click_plugins', 'click_repl',
|
||||||
|
# LUU Y: KHONG exclude 'click' — uvicorn.main import click (CLI parser)!
|
||||||
|
'dateutil', 'pytz', 'tzdata', 'msgpack', 'yaml',
|
||||||
|
# khong dung trong desktop
|
||||||
|
'matplotlib', 'pandas', 'IPython', 'jupyter', 'pytest', 'setuptools',
|
||||||
|
# keo vao nham boi hooks_contrib (app khong import bao gio)
|
||||||
|
'PIL', 'Pillow', 'cairosvg', 'zstandard', 'imageio',
|
||||||
|
]
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['desktop_engine.py'],
|
||||||
|
pathex=[_SPEC_ROOT],
|
||||||
|
binaries=binaries,
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=_hidden,
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=['tkinter'],
|
excludes=_excludes,
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
cipher=None,
|
cipher=None,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["msi"],
|
"targets": ["nsis", "msi"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
Reference in New Issue
Block a user