Initial commit: RHINE LAB · ANALYSIS OS:三维界面与动效实验(本仓库不含个人归档索引数据)
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// Reproduce typing clicks from the user's local reference video.
|
||||
// node scripts/extract-typing-audio.mjs path/to/ffmpeg.exe
|
||||
// These short excerpts retain the source recording's provenance, not the score's license.
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
const ffmpeg = process.argv[2],
|
||||
video = fs.readdirSync(".").find((x) => x.endsWith(".mp4"));
|
||||
if (!ffmpeg || !video)
|
||||
throw Error("A local reference MP4 and FFmpeg path are required");
|
||||
const rate = 48000,
|
||||
start = 6.84,
|
||||
seconds = 0.55;
|
||||
const bytes = execFileSync(
|
||||
ffmpeg,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-ss",
|
||||
String(start),
|
||||
"-i",
|
||||
video,
|
||||
"-t",
|
||||
String(seconds),
|
||||
"-vn",
|
||||
"-af",
|
||||
"pan=mono|c0=0.5*c0+0.5*c1",
|
||||
"-ar",
|
||||
String(rate),
|
||||
"-f",
|
||||
"f32le",
|
||||
"pipe:1",
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const source = new Float32Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.length / 4,
|
||||
);
|
||||
const cuts = [6.864, 6.906, 6.948],
|
||||
duration = 0.038;
|
||||
const samples = cuts.map((time) =>
|
||||
source.slice(
|
||||
Math.round((time - start) * rate),
|
||||
Math.round((time - start + duration) * rate),
|
||||
),
|
||||
);
|
||||
let peak = 0;
|
||||
for (const sample of samples) {
|
||||
const dc = sample.reduce((sum, x) => sum + x, 0) / sample.length;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const fadeIn = Math.min(1, i / (rate * 0.00035)),
|
||||
fadeOut = Math.min(1, (sample.length - 1 - i) / (rate * 0.0035));
|
||||
sample[i] = (sample[i] - dc) * fadeIn * fadeOut;
|
||||
peak = Math.max(peak, Math.abs(sample[i]));
|
||||
}
|
||||
}
|
||||
// Shared gain preserves the three original clicks' relative accents.
|
||||
const gain = 0.3 / peak;
|
||||
const pcm = samples.map((sample) => {
|
||||
const out = Buffer.alloc(sample.length * 2);
|
||||
for (let i = 0; i < sample.length; i++)
|
||||
out.writeInt16LE(Math.round(sample[i] * gain * 32767), i * 2);
|
||||
return out;
|
||||
});
|
||||
fs.mkdirSync("public/audio", { recursive: true });
|
||||
function wav(data, file) {
|
||||
const out = Buffer.alloc(44 + data.length);
|
||||
out.write("RIFF");
|
||||
out.writeUInt32LE(out.length - 8, 4);
|
||||
out.write("WAVEfmt ", 8);
|
||||
out.writeUInt32LE(16, 16);
|
||||
out.writeUInt16LE(1, 20);
|
||||
out.writeUInt16LE(1, 22);
|
||||
out.writeUInt32LE(rate, 24);
|
||||
out.writeUInt32LE(rate * 2, 28);
|
||||
out.writeUInt16LE(2, 32);
|
||||
out.writeUInt16LE(16, 34);
|
||||
out.write("data", 36);
|
||||
out.writeUInt32LE(data.length, 40);
|
||||
data.copy(out, 44);
|
||||
fs.writeFileSync(file, out);
|
||||
}
|
||||
// Review-only source excerpt. Uniform gain makes its level comparable to the rebuilt sequence.
|
||||
const referencePcm = Buffer.alloc(source.length * 2);
|
||||
for (let i = 0; i < source.length; i++)
|
||||
referencePcm.writeInt16LE(
|
||||
Math.round(Math.max(-1, Math.min(1, source[i] * gain * 0.2)) * 32767),
|
||||
i * 2,
|
||||
);
|
||||
wav(referencePcm, "reference/typing-original.wav");
|
||||
const preview = Buffer.alloc(rate * 2 * 2);
|
||||
const pulseTimes = [
|
||||
0.02, 0.064, 0.108, 0.152, 0.2, 0.244, 0.288, 0.332, 0.38, 0.424, 0.468,
|
||||
0.516, 1.02, 1.068, 1.116, 1.16, 1.208, 1.256, 1.304, 1.352,
|
||||
];
|
||||
pulseTimes.forEach((t, n) => {
|
||||
const click = pcm[n % pcm.length],
|
||||
offset = Math.round(t * rate);
|
||||
for (let i = 0; i < click.length / 2; i++)
|
||||
preview.writeInt16LE(
|
||||
Math.round(click.readInt16LE(i * 2) * 0.2),
|
||||
(offset + i) * 2,
|
||||
);
|
||||
});
|
||||
wav(preview, "public/audio/typing-preview.wav");
|
||||
fs.writeFileSync(
|
||||
"src/typing-samples.ts",
|
||||
`// Generated by scripts/extract-typing-audio.mjs. Source provenance: public/audio/typing-source.json.\nexport const TYPING_SAMPLE_RATE = ${rate};\nexport const TYPING_PCM = ${JSON.stringify(
|
||||
pcm.map((x) => x.toString("base64")),
|
||||
null,
|
||||
2,
|
||||
)} as const;\n`,
|
||||
);
|
||||
const report = {
|
||||
source: video,
|
||||
sourceSha256: crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(video))
|
||||
.digest("hex"),
|
||||
sampleRate: rate,
|
||||
channels: 1,
|
||||
cuts: cuts.map((start) => ({ start, end: +(start + duration).toFixed(3) })),
|
||||
processing: {
|
||||
dcRemoval: true,
|
||||
fadeInMs: 0.35,
|
||||
fadeOutMs: 3.5,
|
||||
sharedGain: gain,
|
||||
denoising: false,
|
||||
pitchChange: false,
|
||||
},
|
||||
scope:
|
||||
"Three short excerpts from user-provided reference. Not original synthesis; source rights remain with the original creators.",
|
||||
};
|
||||
fs.writeFileSync(
|
||||
"public/audio/typing-source.json",
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ cuts: report.cuts, bytes: pcm.reduce((n, b) => n + b.length, 0), gain },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user