refactor(bridge): split fx bridge, stabilize VST3 host chain, drop old install engines
- remove diagnostic canaries from audio hot path (crackle fix) and stuck C5 note raw-MIDI test (Vst2Instrument::init noteOff) - split native bridge into daw_vst_bridge (VSTi) + fx_vst_bridge (VST FX) - VST3: processData prepare before setActive, zero input buses, accept both .vstpreset and bridge-state blob in RenderJob presets - native_render/vst_engine: bridge-exe discovery + render_fx_chain + /fx-gui - delete old install/ engine binaries (install via sidecar+resources now) - add BUILD_LAYOUT.md, build_selfcheck, tools/ diagnostics
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Patch app/static/js/app.jsx: thay render per-item (scheduleNativeSfItem /
|
||||
scheduleNativeVstItem) bằng scheduleNativeTrack (render cả track 1 request)
|
||||
+ epoch guard + decode queue + prerenderNativeTracks. Debug tool — không commit."""
|
||||
import io, sys
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
PATH = "app/static/js/app.jsx"
|
||||
with open(PATH, "rb") as f:
|
||||
raw = f.read()
|
||||
src = raw.decode("utf-8").replace("\r\n", "\n")
|
||||
orig = src
|
||||
|
||||
|
||||
def save():
|
||||
with open(PATH, "wb") as f:
|
||||
f.write(src.replace("\n", "\r\n").encode("utf-8"))
|
||||
|
||||
|
||||
# ── Patch 1: thay scheduleNativeSfItem (per-item) bằng họ scheduleNativeTrack ──
|
||||
if "_nativePlayEpoch" not in src:
|
||||
OLD_START = "// \u2500\u2500 Native soundfont item render (standalone, transport) \u2500"
|
||||
OLD_END = " } catch (e) { console.warn('[NativeSF] scheduleNativeSfItem error:', e); }\n};"
|
||||
i0 = src.index(OLD_START)
|
||||
i1 = src.index(OLD_END) + len(OLD_END)
|
||||
assert src[i1:i1+4] == "\n\n//", repr(src[i1:i1+40])
|
||||
NEW = """// \u2500\u2500 Native track render (standalone, transport) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
// Render TO\u00c0N B\u1ed8 MIDI items c\u1ee7a track b\u1eb1ng 1 request (native FluidSynth cho
|
||||
// soundfont, native_bridge --render cho VSTi) \u2192 decode \u2192 1 BufferSource duy
|
||||
// nh\u1ea5t cho c\u1ea3 track (nh\u01b0 audio clip). Thay th\u1ebf render per-item: RENDER_LOCK
|
||||
// server serialize t\u1eebng item \u2192 project nhi\u1ec1u item = delay v\u00e0i ch\u1ee5c gi\u00e2y.
|
||||
// opts:
|
||||
// baseOffsetSec: offset th\u00eam (section: secStart) | limitSec: ch\u1eb7n t\u1ea1i secEnd
|
||||
// isActive(): guard stop gi\u1eefa ch\u1eebng | sources: m\u1ea3ng ngu\u1ed3n \u0111\u1ec3 stop
|
||||
// playheadNow(): playhead TH\u1eacT t\u1ea1i th\u1eddi \u0111i\u1ec3m resolve \u2014 render tr\u1ec5 v\u00e0i ch\u1ee5c
|
||||
// gi\u00e2y \u2192 item v\u1eabn schedule \u0111\u00fang v\u1ecb tr\u00ed playhead hi\u1ec7n t\u1ea1i (kh\u00f4ng ch\u1ed3ng
|
||||
// \u00e2m/wall-of-sound nh\u01b0 d\u00f9ng offsetTime c\u0169).
|
||||
// notesOverride: d\u00f9ng thay track.midiItems (piano roll tab).
|
||||
// Epoch guard: stop/rebuild t\u0103ng _nativePlayEpoch \u2192 m\u1ecdi render pending c\u1ee7a
|
||||
// l\u1ea7n play c\u0169 resolve nh\u01b0ng b\u1ecb skip (kh\u00f4ng duplicate schedule).
|
||||
let _nativePlayEpoch = 0;
|
||||
const _nativeTrackUrls = {};
|
||||
const _nativeTrackPending = {};
|
||||
const _buildNativeTrackNotes = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||
const secPerBeat = 60.0 / (parseInt(bpm) || 120);
|
||||
const items = notesOverride ? [{ startTime: 0, notes: notesOverride }] : ((track && track.midiItems) || []);
|
||||
let trackStartAbs = Infinity;
|
||||
const absNotes = [];
|
||||
items.forEach(function (item) {
|
||||
const itemStartAbs = (baseOffsetSec || 0) + (item.startTime || 0);
|
||||
if (itemStartAbs < trackStartAbs) trackStartAbs = itemStartAbs;
|
||||
(item.notes || []).forEach(function (n) {
|
||||
absNotes.push({
|
||||
pitch: n.pitch || 60,
|
||||
start_beat: itemStartAbs / secPerBeat + (n.start_beat || 0),
|
||||
duration_beats: n.duration_beats || 1,
|
||||
velocity: n.velocity != null ? n.velocity : 0.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
return { absNotes: absNotes, trackStartAbs: trackStartAbs };
|
||||
};
|
||||
const _nativeTrackKey = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||
try {
|
||||
const eng = track && track.synth_engine;
|
||||
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId) || '';
|
||||
const sfId = (eng && eng.soundfont_id) || '';
|
||||
const data = _buildNativeTrackNotes(track, bpm, baseOffsetSec, notesOverride);
|
||||
const nk = data.absNotes.map(function (n) {
|
||||
return n.pitch + ':' + n.start_beat + ':' + n.duration_beats + ':' + n.velocity;
|
||||
}).join(',');
|
||||
return [track && track.id ? track.id : '', instrumentId, sfId, (eng && eng.preset_id) || '', (eng && eng.preset_path) || '', parseFloat(bpm) || 120, baseOffsetSec || 0, nk].join('|');
|
||||
} catch (e) { return ''; }
|
||||
};
|
||||
const _renderNativeTrack = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||
try {
|
||||
const eng = track && track.synth_engine;
|
||||
const data = _buildNativeTrackNotes(track, bpm, baseOffsetSec, notesOverride);
|
||||
if (!data.absNotes.length) return Promise.resolve(null);
|
||||
if (isSfTrackEngine(eng)) {
|
||||
const sfId = eng && eng.soundfont_id;
|
||||
if (!sfId) return Promise.resolve(null);
|
||||
return window.SonicAPI.soundfontRender({
|
||||
soundfont_id: sfId,
|
||||
bank: (eng && eng.soundfont_bank) || 0,
|
||||
program: (eng && eng.soundfont_program) || 0,
|
||||
bpm: parseFloat(bpm) || 120,
|
||||
notes: data.absNotes,
|
||||
}).then(function (res) {
|
||||
if (!res || !res.success || !res.url) return null;
|
||||
return API_BASE_URL + res.url;
|
||||
}).catch(function () { return null; });
|
||||
}
|
||||
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId);
|
||||
if (!instrumentId || !isVstTrackEngine(eng)) return Promise.resolve(null);
|
||||
return window.SonicAPI.midiRender({
|
||||
instrument_id: instrumentId,
|
||||
notes: data.absNotes,
|
||||
bpm: parseFloat(bpm) || 120,
|
||||
preset_id: (eng && eng.preset_id) || undefined,
|
||||
preset_path: (eng && eng.preset_path) || undefined,
|
||||
preset_data: (eng && eng.preset_data) || undefined,
|
||||
}).then(function (res) {
|
||||
if (!res || !res.success || !res.url) return null;
|
||||
return API_BASE_URL + res.url;
|
||||
}).catch(function () { return null; });
|
||||
} catch (e) { return Promise.resolve(null); }
|
||||
};
|
||||
// Dedupe in-flight render + cache WAV: play l\u1eb7p/re-schedule trong l\u00fac render
|
||||
// \u0111ang ch\u1ea1y \u2192 ch\u1edd c\u00f9ng promise, kh\u00f4ng b\u1eafn duplicate request (tr\u00e1nh queue sau
|
||||
// RENDER_LOCK + render 2 l\u1ea7n c\u00f9ng n\u1ed9i dung).
|
||||
const ensureRenderNativeTrack = (track, bpm, baseOffsetSec, notesOverride) => {
|
||||
const key = _nativeTrackKey(track, bpm, baseOffsetSec, notesOverride);
|
||||
if (!key) return _renderNativeTrack(track, bpm, baseOffsetSec, notesOverride);
|
||||
if (_nativeTrackUrls[key]) return Promise.resolve(_nativeTrackUrls[key]);
|
||||
if (_nativeTrackPending[key]) return _nativeTrackPending[key];
|
||||
const p = _renderNativeTrack(track, bpm, baseOffsetSec, notesOverride).then(function (url) {
|
||||
if (url) _nativeTrackUrls[key] = url;
|
||||
return url;
|
||||
}).then(function (url) {
|
||||
delete _nativeTrackPending[key];
|
||||
return url;
|
||||
});
|
||||
_nativeTrackPending[key] = p;
|
||||
return p;
|
||||
};
|
||||
// Gi\u1edbi h\u1ea1n decodeAudioData song song (max 3) \u2014 nhi\u1ec1u WAV fetch v\u1ec1 c\u00f9ng l\u00fac \u2192
|
||||
// main thread ngh\u1ebdn \u2192 UI \u0111\u01a1, Stop kh\u00f4ng ph\u1ea3n h\u1ed3i.
|
||||
let _nativeDecodeActive = 0;
|
||||
const _nativeDecodeQueue = [];
|
||||
const _pumpNativeDecodes = () => {
|
||||
while (_nativeDecodeActive < 3 && _nativeDecodeQueue.length) {
|
||||
const job = _nativeDecodeQueue.shift();
|
||||
_nativeDecodeActive++;
|
||||
try {
|
||||
job.ctx.decodeAudioData(job.buf, function (ab) {
|
||||
_nativeDecodeActive--;
|
||||
job.cb(ab);
|
||||
_pumpNativeDecodes();
|
||||
}, function () {
|
||||
_nativeDecodeActive--;
|
||||
job.cb(null);
|
||||
_pumpNativeDecodes();
|
||||
});
|
||||
} catch (e) {
|
||||
_nativeDecodeActive--;
|
||||
job.cb(null);
|
||||
_pumpNativeDecodes();
|
||||
}
|
||||
}
|
||||
};
|
||||
const _decodeNativeQueue = (ctx, buf, cb) => {
|
||||
_nativeDecodeQueue.push({ ctx: ctx, buf: buf, cb: cb });
|
||||
_pumpNativeDecodes();
|
||||
};
|
||||
const scheduleNativeTrack = (track, offsetTime, context, destNode, bpm, opts) => {
|
||||
try {
|
||||
const eng = track && track.synth_engine;
|
||||
if (!isSfTrackEngine(eng) && !isVstTrackEngine(eng)) return;
|
||||
const baseOffsetSec = (opts && opts.baseOffsetSec) || 0;
|
||||
const notesOverride = (opts && opts.notesOverride) || null;
|
||||
const data = _buildNativeTrackNotes(track, bpm, baseOffsetSec, notesOverride);
|
||||
if (!data.absNotes.length) return;
|
||||
const key = _nativeTrackKey(track, bpm, baseOffsetSec, notesOverride);
|
||||
const epoch = _nativePlayEpoch;
|
||||
const scheduleUrl = function (url) {
|
||||
if (!url) return;
|
||||
fetch(url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) {
|
||||
_decodeNativeQueue(context, buf, function (audioBuf) {
|
||||
try {
|
||||
if (!audioBuf) return;
|
||||
if (epoch !== _nativePlayEpoch) return; // stale play (stop/rebuild)
|
||||
if (opts && typeof opts.isActive === 'function' && !opts.isActive()) return;
|
||||
const ph = (opts && typeof opts.playheadNow === 'function') ? opts.playheadNow() : offsetTime;
|
||||
const src = context.createBufferSource();
|
||||
src.buffer = audioBuf;
|
||||
src.connect(destNode);
|
||||
const trackEndAbs = data.trackStartAbs + audioBuf.duration;
|
||||
if (ph < data.trackStartAbs) {
|
||||
src.start(context.currentTime + (data.trackStartAbs - ph), 0);
|
||||
} else if (ph < trackEndAbs) {
|
||||
src.start(context.currentTime, Math.min(ph - data.trackStartAbs, Math.max(0, audioBuf.duration - 0.05)));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (opts && opts.limitSec && opts.limitSec < trackEndAbs) {
|
||||
src.stop(context.currentTime + Math.max(0.02, opts.limitSec - Math.max(ph, data.trackStartAbs)));
|
||||
}
|
||||
if (opts && opts.sources && opts.sources.push) opts.sources.push(src);
|
||||
} catch (e) { console.warn('[NativeTrack] schedule decode error:', e); }
|
||||
});
|
||||
}).catch(function () {});
|
||||
};
|
||||
if (key && _nativeTrackUrls[key]) { scheduleUrl(_nativeTrackUrls[key]); return; }
|
||||
ensureRenderNativeTrack(track, bpm, baseOffsetSec, notesOverride).then(function (url) {
|
||||
if (!url) return;
|
||||
scheduleUrl(url);
|
||||
});
|
||||
} catch (e) { console.warn('[NativeTrack] scheduleNativeTrack error:', e); }
|
||||
};"""
|
||||
src = src[:i0] + NEW + src[i1:]
|
||||
print("OK patch 1 (scheduleNativeSfItem -> scheduleNativeTrack)")
|
||||
else:
|
||||
print("SKIP patch 1 (da ap)")
|
||||
|
||||
# ── Patch 2: URL cache per-item + prerenderNativeVstItems → prerenderNativeTracks ──
|
||||
if "prerenderNativeTracks" not in src:
|
||||
P2_START = "// URL cache cho midi-render: key = fingerprint (item+instrument+preset+notes)"
|
||||
P2_END = " }, 800);\n } catch (e) {}\n};"
|
||||
i0 = src.index(P2_START)
|
||||
i1 = src.index(P2_END, i0) + len(P2_END)
|
||||
P2 = """// \u2500\u2500 Native track pre-render (standalone) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
// Render C\u1ea2 TRACK 1 request khi project load \u2192 play l\u1ea7n \u0111\u1ea7u kh\u00f4ng ch\u1edd render.
|
||||
let _prerenderNativeTracksTimer = null;
|
||||
const prerenderNativeTracks = (tracks, bpm) => {
|
||||
try {
|
||||
if (!isStandaloneSf()) return;
|
||||
if (_prerenderNativeTracksTimer) { clearTimeout(_prerenderNativeTracksTimer); _prerenderNativeTracksTimer = null; }
|
||||
_prerenderNativeTracksTimer = setTimeout(function () {
|
||||
_prerenderNativeTracksTimer = null;
|
||||
const seen = {};
|
||||
(tracks || []).forEach(function (track) {
|
||||
const eng = track && track.synth_engine;
|
||||
const native = isStandaloneSf() && (isVstTrackEngine(eng) || isSfTrackEngine(eng)) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
|
||||
if (!native) return;
|
||||
const key = _nativeTrackKey(track, bpm, 0, null);
|
||||
if (!key || seen[key] || _nativeTrackUrls[key]) return;
|
||||
seen[key] = true;
|
||||
ensureRenderNativeTrack(track, bpm, 0, null).then(function () {});
|
||||
});
|
||||
}, 800);
|
||||
} catch (e) {}
|
||||
};"""
|
||||
src = src[:i0] + P2 + src[i1:]
|
||||
print("OK patch 2 (prerenderNativeVstItems -> prerenderNativeTracks)")
|
||||
else:
|
||||
print("SKIP patch 2 (da ap)")
|
||||
|
||||
# ── Patch 3: xóa scheduleNativeVstItem (không còn dùng) ──
|
||||
if "scheduleNativeVstItem" in src:
|
||||
P3_START = "// \u2500\u2500 Native VSTi item render (standalone, transport) \u2500"
|
||||
P3_END = " } catch (e) { console.warn('[NativeVST] scheduleNativeVstItem error:', e); }\n};"
|
||||
i0 = src.index(P3_START)
|
||||
i1 = src.index(P3_END) + len(P3_END)
|
||||
assert src[i1:i1+2] == "\n\n", repr(src[i1:i1+30])
|
||||
src = src[:i0] + src[i1+2:]
|
||||
print("OK patch 3 (xoa scheduleNativeVstItem)")
|
||||
else:
|
||||
print("SKIP patch 3 (da ap)")
|
||||
|
||||
# ── Patch 4: đổi 4 call sites sang scheduleNativeTrack ──
|
||||
C4 = [
|
||||
# site 1: startTrackPlayback
|
||||
(""" if (nativeSf) {
|
||||
midiItems.forEach(item => scheduleNativeSfItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
||||
} else if (nativeVst) {
|
||||
midiItems.forEach(item => scheduleNativeVstItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
||||
} else {""",
|
||||
""" if (nativeSf || nativeVst) {
|
||||
scheduleNativeTrack(track, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||
} else {"""),
|
||||
# site 2: section playback
|
||||
(""" if (subNativeSf) {
|
||||
subMidiItems.forEach(item => scheduleNativeSfItem(subTrack, item, offsetTime, context, subNode, bpm, { baseOffsetSec: secStart, limitSec: secEnd, sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
||||
return; // native render \u2192 audio buffer (kh\u00f4ng qua WASM/Carla per-note)
|
||||
}
|
||||
if (subNativeVst) {
|
||||
subMidiItems.forEach(item => scheduleNativeVstItem(subTrack, item, offsetTime, context, subNode, bpm, { baseOffsetSec: secStart, limitSec: secEnd, sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
||||
return; // native render \u2192 audio buffer (kh\u00f4ng qua WASM/Carla per-note)
|
||||
}""",
|
||||
""" if (subNativeSf || subNativeVst) {
|
||||
scheduleNativeTrack(subTrack, offsetTime, context, subNode, bpm, { baseOffsetSec: secStart, limitSec: secEnd, sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||
return; // native render \u2192 audio buffer (kh\u00f4ng qua WASM/Carla per-note)
|
||||
}"""),
|
||||
# site 3: startLocalTrackPlayback
|
||||
(""" if (nativeSf) {
|
||||
midiItems.forEach(item => scheduleNativeSfItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
||||
return; // native render \u2192 audio buffer (kh\u00f4ng qua WASM/Carla per-note)
|
||||
}
|
||||
if (nativeVst) {
|
||||
midiItems.forEach(item => scheduleNativeVstItem(track, item, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current }));
|
||||
return; // native render \u2192 audio buffer (kh\u00f4ng qua WASM/Carla per-note)
|
||||
}""",
|
||||
""" if (nativeSf || nativeVst) {
|
||||
scheduleNativeTrack(track, offsetTime, context, gainNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||
return; // native render \u2192 audio buffer (kh\u00f4ng qua WASM/Carla per-note)
|
||||
}"""),
|
||||
# site 4: piano roll (notesOverride)
|
||||
(""" if (isStandaloneSf() && !routeCarla && isSfTrackEngine(synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive())) {
|
||||
scheduleNativeSfItem(track, { startTime: 0, notes: midiNotes }, offsetSeconds, context, destNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||
return; // native render c\u1ea3 tab \u2192 audio buffer (kh\u00f4ng per-note WASM/Carla)
|
||||
}
|
||||
if (isStandaloneSf() && !routeCarla && isVstTrackEngine(synthEngine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive())) {
|
||||
scheduleNativeVstItem(track, { startTime: 0, notes: midiNotes }, offsetSeconds, context, destNode, bpm, { sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||
return; // native render c\u1ea3 tab \u2192 audio buffer (kh\u00f4ng per-note WASM/Carla)
|
||||
}""",
|
||||
""" if (isStandaloneSf() && !routeCarla && (isSfTrackEngine(synthEngine) || isVstTrackEngine(synthEngine)) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive())) {
|
||||
scheduleNativeTrack(track, offsetSeconds, context, destNode, bpm, { notesOverride: midiNotes, sources: activeSourcesRef.current, isActive: () => !!isPlayingRef.current });
|
||||
return; // native render c\u1ea3 tab \u2192 audio buffer (kh\u00f4ng per-note WASM/Carla)
|
||||
}"""),
|
||||
]
|
||||
n4 = 0
|
||||
for old, new in C4:
|
||||
if old in src:
|
||||
src = src.replace(old, new, 1)
|
||||
n4 += 1
|
||||
print("OK patch 4 (%d/4 call sites)" % n4)
|
||||
|
||||
# ── Patch 5: stopAllPlayback tăng epoch ──
|
||||
if "_nativePlayEpoch++" not in src:
|
||||
anchor = " try {\n isPlayingRef.current = false;"
|
||||
assert src.count(anchor) == 1, "anchor stopAllPlayback khong duy nhat"
|
||||
src = src.replace(anchor, " try {\n isPlayingRef.current = false;\n _nativePlayEpoch++; // h\u1ee7y m\u1ecdi render/decode pending c\u1ee7a l\u1ea7n play c\u0169", 1)
|
||||
print("OK patch 5 (epoch guard trong stopAllPlayback)")
|
||||
else:
|
||||
print("SKIP patch 5 (da ap)")
|
||||
|
||||
# ── Patch 6: đổi call sites prerender ──
|
||||
n6 = src.count("prerenderNativeVstItems(")
|
||||
if n6:
|
||||
src = src.replace("prerenderNativeVstItems(", "prerenderNativeTracks(")
|
||||
print("OK patch 6 (%d prerender call sites)" % n6)
|
||||
else:
|
||||
print("SKIP patch 6 (da ap)")
|
||||
|
||||
save()
|
||||
print("DONE. changed=", src != orig, "len=", len(src))
|
||||
Reference in New Issue
Block a user