D3+D4: export stage PCM16/24 + TPDF dither + noise shaping, resample 44.1/48/96k, normalize -1dBTP/-14LUFS, mono side/mid check; Export modal mastering options + /plugins/render options (test 9 case, 23 passed)
This commit is contained in:
@@ -375,6 +375,31 @@ def _probe_track_latency(track, sample_rate):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _quantize_pcm(audio, bits, dither):
|
||||
"""Quantize float [-1,1] to PCM grid (bits=16|24), optional TPDF dither +
|
||||
1st-order noise shaping. Tra ve int array de sf.write ghi EXACT bits
|
||||
(libsndfile float->PCM khong round dung 32768-grid - kiem chung D3)."""
|
||||
N = 2 ** (bits - 1)
|
||||
x = audio.astype(np.float64)
|
||||
if dither and bits < 32:
|
||||
lsb = 1.0 / N
|
||||
rng = np.random.default_rng()
|
||||
d = (rng.random(x.shape) + rng.random(x.shape) - 1.0) * lsb # TPDF [-lsb, lsb]
|
||||
out = np.empty_like(x)
|
||||
err = np.zeros(x.shape[0])
|
||||
for i in range(x.shape[1]):
|
||||
s = x[:, i] + d[:, i] + err
|
||||
q = np.rint(s * N)
|
||||
out[:, i] = q
|
||||
err = q / N - s # 1st-order noise shaping feedback
|
||||
q = np.clip(out, -N, N - 1)
|
||||
else:
|
||||
q = np.clip(np.rint(x * N), -N, N - 1)
|
||||
if bits == 16:
|
||||
return q.astype(np.int16)
|
||||
# PCM_24: libsndfile luu top-24-bit cua int32 -> <<8
|
||||
return (q.astype(np.int64) << 8).astype(np.int32)
|
||||
|
||||
class PythonRenderEngine:
|
||||
def __init__(self, sample_rate=44100):
|
||||
self.sample_rate = sample_rate
|
||||
@@ -765,7 +790,10 @@ class PythonRenderEngine:
|
||||
|
||||
return session_buffer
|
||||
|
||||
def render_project(self, project_json: dict, output_filepath: str):
|
||||
def render_project(self, project_json: dict, output_filepath: str,
|
||||
bit_depth: int = 32, out_sample_rate: int = 0,
|
||||
dither: bool = False, normalize: bool = False,
|
||||
normalize_target: str = "-1dbtp"):
|
||||
with RENDER_LOCK:
|
||||
bpm = project_json["metadata"]["bpm"]
|
||||
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
|
||||
@@ -854,6 +882,31 @@ class PythonRenderEngine:
|
||||
if max_peak > 1.0:
|
||||
master_buffer /= max_peak
|
||||
|
||||
# Write final output file
|
||||
sf.write(output_filepath, master_buffer.T, self.sample_rate, subtype="FLOAT")
|
||||
# ── D3 export stage: resample / normalize / dither / PCM ──
|
||||
out_sr = out_sample_rate or self.sample_rate
|
||||
if out_sr != self.sample_rate:
|
||||
from scipy.signal import resample_poly
|
||||
g = math.gcd(int(out_sr), int(self.sample_rate))
|
||||
master_buffer = resample_poly(
|
||||
master_buffer, int(out_sr) // g, int(self.sample_rate) // g, axis=1)
|
||||
if normalize:
|
||||
from app.core import loudness
|
||||
if normalize_target == "-14lufs":
|
||||
_l = loudness.integrated_lufs(master_buffer, out_sr)
|
||||
if np.isfinite(_l):
|
||||
master_buffer *= 10 ** ((-14.0 - _l) / 20.0)
|
||||
else: # "-1dbtp"
|
||||
_tp = loudness.true_peak_db(master_buffer, out_sr)
|
||||
if np.isfinite(_tp):
|
||||
master_buffer *= 10 ** ((-1.0 - _tp) / 20.0)
|
||||
|
||||
# Write final output file (D3: PCM_16/PCM_24 + dither, FLOAT mac dinh)
|
||||
if bit_depth == 16:
|
||||
sf.write(output_filepath, _quantize_pcm(master_buffer, 16, dither).T,
|
||||
out_sr, subtype="PCM_16")
|
||||
elif bit_depth == 24:
|
||||
sf.write(output_filepath, _quantize_pcm(master_buffer, 24, dither).T,
|
||||
out_sr, subtype="PCM_24")
|
||||
else:
|
||||
sf.write(output_filepath, master_buffer.T, out_sr, subtype="FLOAT")
|
||||
return output_filepath
|
||||
|
||||
Reference in New Issue
Block a user