147 lines
4.3 KiB
JavaScript
147 lines
4.3 KiB
JavaScript
// 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,
|
|
),
|
|
);
|