Files
rhinelab-ui/scripts/extract-typing-audio.mjs
T

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,
),
);