Normalize Linux audio pipeline to lossless Float32 (skill: lossless-audio-compliance)

- native bridge WAV writers: 16-bit PCM truncation -> IEEE Float32 (fmt 3, bit-exact)
- vendor sheredom_json.h (vst3sdk 3.8.1 lacks moduleinfo/json.h)
- Dockerfile: drop nonexistent sfizz tag v1.2.3
- Python sf.write -> subtype=FLOAT everywhere
- resample_poly -> kaiser(16) window (>=130dB stopband)
- audio_editor._write_wav_lossless(): FLOAT passthrough, TPDF dither only for
  8/16/24-bit final exports
- app.jsx WAV encoders: Float32 default, TPDF dither for integer depths, fix 24-bit
- tests: lossless compliance gate (null test < -140dBFS, float32 format) 9/9 pass
- TASKS_WINDOWS_AUDIO_NORMALIZE.md: Windows-side remaining tasks
This commit is contained in:
2026-08-18 11:50:26 +07:00
parent c382afe2a6
commit a2f93138be
18 changed files with 3842 additions and 72 deletions
+34 -22
View File
@@ -11683,7 +11683,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Định dạng</label>
<select value={exportSettings.format}
onChange={e => setExportSettings(p => ({ ...p, format: e.target.value, sampleRate: '44100', bitDepth: '16', quality: '44khz' }))}
onChange={e => setExportSettings(p => ({ ...p, format: e.target.value, sampleRate: '44100', bitDepth: 'float', quality: '44khz' }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="wav">WAV</option>
<option value="mp3">MP3</option>
@@ -11708,6 +11708,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
<option value="8">8</option>
<option value="16">16</option>
<option value="24">24</option>
<option value="float">32 float</option>
</select>
</div>
</div>
@@ -16832,7 +16833,7 @@ const App = () => {
const [selectedProviderId, setSelectedProviderId] = useState('');
const [exportSettings, setExportSettings] = useState({
sampleRate: '44100',
bitDepth: '16',
bitDepth: 'float',
format: 'wav',
source: 'project',
quality: '44khz',
@@ -20130,8 +20131,8 @@ const App = () => {
const sr = buffer.sampleRate;
const monoData = buffer.getChannelData(0);
const bufferLength = monoData.length;
const bitDepth = 16;
const bytesPerSample = 2;
const bitDepth = 32;
const bytesPerSample = 4;
const headerSize = 44;
const fileSizeBytes = headerSize + bufferLength * bytesPerSample;
const fileBuffer = new ArrayBuffer(fileSizeBytes);
@@ -20146,7 +20147,7 @@ const App = () => {
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(20, 3, true); // IEEE float (lossless)
view.setUint16(22, 1, true);
view.setUint32(24, sr, true);
view.setUint32(28, sr * bytesPerSample, true);
@@ -20156,8 +20157,7 @@ const App = () => {
view.setUint32(40, bufferLength * bytesPerSample, true);
let offset = 44;
for (let i = 0; i < bufferLength; i++) {
const sample = Math.max(-1, Math.min(1, monoData[i]));
view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true);
view.setFloat32(offset, monoData[i], true); // bit-exact, no clamp/quantize
offset += bytesPerSample;
}
const blob = new Blob([view], {
@@ -21249,6 +21249,10 @@ const App = () => {
// ── Server-side upload ──
// WAV encoder tối thiểu — upload buffer-only clip khi LƯU (nếu chưa có
// serverFileId — upload sớm thất bại/race → clip mất sau reload).
// ── Lossless WAV encode helpers (lossless-audio-compliance skill) ─────────
// Internal clip/IPC + default export = IEEE Float32 (format 3), bit-exact.
// Single-stage TPDF dither applied ONLY at final PCM quantization (16/24/8).
const tpdfDither = v => v + (Math.random() - Math.random());
const encodeWavBlob = (audioBuffer) => {
const numCh = Math.max(1, audioBuffer.numberOfChannels || 1);
const sr = audioBuffer.sampleRate || 44100;
@@ -21258,18 +21262,15 @@ const App = () => {
const data = audioBuffer.getChannelData(ch);
for (let i = 0; i < len; i++) interleaved[i * numCh + ch] = data[i];
}
const buffer = new ArrayBuffer(44 + interleaved.length * 2);
const buffer = new ArrayBuffer(44 + interleaved.length * 4);
const view = new DataView(buffer);
const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
writeStr(0, 'RIFF'); view.setUint32(4, 36 + interleaved.length * 2, true); writeStr(8, 'WAVE');
writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
writeStr(0, 'RIFF'); view.setUint32(4, 36 + interleaved.length * 4, true); writeStr(8, 'WAVE');
writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 3, true); // IEEE float
view.setUint16(22, numCh, true); view.setUint32(24, sr, true);
view.setUint32(28, sr * numCh * 2, true); view.setUint16(32, numCh * 2, true); view.setUint16(34, 16, true);
writeStr(36, 'data'); view.setUint32(40, interleaved.length * 2, true);
for (let i = 0; i < interleaved.length; i++) {
const s = Math.max(-1, Math.min(1, interleaved[i]));
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
view.setUint32(28, sr * numCh * 4, true); view.setUint16(32, numCh * 4, true); view.setUint16(34, 32, true);
writeStr(36, 'data'); view.setUint32(40, interleaved.length * 4, true);
for (let i = 0; i < interleaved.length; i++) view.setFloat32(44 + i * 4, interleaved[i], true);
return new Blob([buffer], { type: 'audio/wav' });
};
const uploadToServer = async (file, trackId) => {
@@ -25903,8 +25904,10 @@ const App = () => {
durationLimit += 1.2; // FX/mastering tail
const captureRate = audioCtx.sampleRate || 44100;
const bitDepth = parseInt(exportSettings.bitDepth) || 16;
const isFloatBit = exportSettings.bitDepth === 'float';
const bitDepth = isFloatBit ? 32 : parseInt(exportSettings.bitDepth) || 16;
const outChannels = exportSettings.channels === 'mono' ? 1 : 2;
const fmtTag = isFloatBit ? 3 : 1; // IEEE float vs PCM
showToast(`Bounce realtime ~${Math.round(durationLimit)}s — giữ nguyên âm thanh đang phát...`, "info");
// Capture tap on the master bus output (post-FX + post-mastering)
@@ -25940,13 +25943,13 @@ const App = () => {
let off = 0;
chunks.forEach(c => { stereoBuf.set(c, off); off += c.length; });
const frames = totalFrames;
const bytesPerSample = bitDepth / 8;
const bytesPerSample = isFloatBit ? 4 : bitDepth / 8;
const headerSize = 44;
const fileSizeBytes = headerSize + frames * bytesPerSample * outChannels;
const view = new DataView(new ArrayBuffer(fileSizeBytes));
const writeString = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
writeString(0, 'RIFF'); view.setUint32(4, fileSizeBytes - 8, true); writeString(8, 'WAVE');
writeString(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
writeString(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, fmtTag, true);
view.setUint16(22, outChannels, true); view.setUint32(24, captureRate, true);
view.setUint32(28, captureRate * bytesPerSample * outChannels, true);
view.setUint16(32, bytesPerSample, true); view.setUint16(34, bitDepth, true);
@@ -25954,9 +25957,18 @@ const App = () => {
let o = 44;
for (let i = 0; i < frames; i++) {
for (let ch = 0; ch < outChannels; ch++) {
const s = Math.max(-1, Math.min(1, stereoBuf[i * 2 + ch]));
if (bitDepth === 16) view.setInt16(o, Math.floor(s < 0 ? s * 0x8000 : s * 0x7FFF), true);
else view.setUint8(o, Math.floor((s + 1) * 127.5), true);
const raw = stereoBuf[i * 2 + ch];
if (isFloatBit) {
view.setFloat32(o, raw, true); // bit-exact, no clamp/quantize
} else {
// Single-stage TPDF dither, then clamp, then quantize (final export only)
const s = Math.max(-1, Math.min(1, tpdfDither(raw)));
if (bitDepth === 24) {
const v = Math.floor(s < 0 ? s * 0x800000 : s * 0x7FFFFF) & 0xFFFFFF;
view.setUint8(o, v & 0xFF); view.setUint8(o + 1, (v >> 8) & 0xFF); view.setUint8(o + 2, (v >> 16) & 0xFF);
} else if (bitDepth === 16) view.setInt16(o, Math.floor(s < 0 ? s * 0x8000 : s * 0x7FFF), true);
else view.setUint8(o, Math.floor((s + 1) * 127.5), true);
}
o += bytesPerSample;
}
}
File diff suppressed because one or more lines are too long