Initial release
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* audioCrop.ts — Manual crop service for audio files.
|
||||
*
|
||||
* Crops a WAV file to a specified [inPoint, outPoint] range in seconds.
|
||||
* Also handles companion LRC files by filtering out-of-range lines and
|
||||
* shifting remaining timestamps by -inPoint.
|
||||
*
|
||||
* Used by the manual trim/crop UI feature. WAV parsing follows the same
|
||||
* pattern as autoTrim.ts.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
// ── WAV parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface WavInfo {
|
||||
sampleRate: number;
|
||||
numChannels: number;
|
||||
bitsPerSample: number;
|
||||
dataOffset: number;
|
||||
dataSize: number;
|
||||
}
|
||||
|
||||
function parseWavHeader(buf: Buffer): WavInfo {
|
||||
if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
|
||||
throw new Error('Not a valid WAV file');
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
let fmtFound = false;
|
||||
let sampleRate = 0;
|
||||
let numChannels = 0;
|
||||
let bitsPerSample = 0;
|
||||
let dataOffset = 0;
|
||||
let dataSize = 0;
|
||||
|
||||
while (offset < buf.length - 8) {
|
||||
const chunkId = buf.toString('ascii', offset, offset + 4);
|
||||
const chunkSize = buf.readUInt32LE(offset + 4);
|
||||
|
||||
if (chunkId === 'fmt ') {
|
||||
numChannels = buf.readUInt16LE(offset + 10);
|
||||
sampleRate = buf.readUInt32LE(offset + 12);
|
||||
bitsPerSample = buf.readUInt16LE(offset + 22);
|
||||
fmtFound = true;
|
||||
} else if (chunkId === 'data') {
|
||||
dataOffset = offset + 8;
|
||||
dataSize = chunkSize;
|
||||
break;
|
||||
}
|
||||
offset += 8 + chunkSize;
|
||||
if (chunkSize % 2 !== 0) offset++;
|
||||
}
|
||||
|
||||
if (!fmtFound || dataOffset === 0) {
|
||||
throw new Error('WAV file missing fmt or data chunk');
|
||||
}
|
||||
|
||||
return { sampleRate, numChannels, bitsPerSample, dataOffset, dataSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a WAV file's duration in seconds from its header (cheap — reads only the
|
||||
* first 8 KB, enough to find the fmt + data chunks). Returns 0 on any failure
|
||||
* or for non-WAV input. Used to backfill song duration when the LM/request did
|
||||
* not provide one (e.g. repaint/cover tasks where the LM is skipped).
|
||||
*/
|
||||
export function wavDurationSec(filePath: string): number {
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
const head = Buffer.alloc(8192);
|
||||
const n = fs.readSync(fd, head, 0, 8192, 0);
|
||||
fs.closeSync(fd);
|
||||
const info = parseWavHeader(head.subarray(0, n));
|
||||
const bytesPerFrame = info.numChannels * (info.bitsPerSample / 8);
|
||||
if (info.sampleRate <= 0 || bytesPerFrame <= 0) return 0;
|
||||
return info.dataSize / bytesPerFrame / info.sampleRate;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── WAV crop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CropResult {
|
||||
newDurationSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop a WAV file in place to the [inPointSec, outPointSec] range.
|
||||
*
|
||||
* Applies a 5ms cosine crossfade at both cut points to prevent clicks.
|
||||
* Overwrites the file.
|
||||
*/
|
||||
export function cropWavFile(
|
||||
wavPath: string,
|
||||
inPointSec: number,
|
||||
outPointSec: number,
|
||||
): CropResult {
|
||||
const buf = fs.readFileSync(wavPath);
|
||||
const info = parseWavHeader(buf);
|
||||
const bytesPerSample = info.bitsPerSample / 8;
|
||||
const frameSize = bytesPerSample * info.numChannels;
|
||||
const totalSamples = Math.floor(info.dataSize / frameSize);
|
||||
|
||||
// Clamp to valid range
|
||||
const inSample = Math.max(0, Math.floor(inPointSec * info.sampleRate));
|
||||
const outSample = Math.min(totalSamples, Math.floor(outPointSec * info.sampleRate));
|
||||
|
||||
if (outSample <= inSample) {
|
||||
throw new Error('Invalid crop range: outPoint must be after inPoint');
|
||||
}
|
||||
|
||||
const croppedSamples = outSample - inSample;
|
||||
const minSamples = info.sampleRate; // 1 second minimum
|
||||
if (croppedSamples < minSamples) {
|
||||
throw new Error(`Crop result too short (${(croppedSamples / info.sampleRate).toFixed(2)}s). Minimum is 1 second.`);
|
||||
}
|
||||
|
||||
// Build new buffer: header + cropped PCM data
|
||||
const newDataSize = croppedSamples * frameSize;
|
||||
const newBuf = Buffer.alloc(info.dataOffset + newDataSize);
|
||||
|
||||
// Copy header (everything up to data start)
|
||||
buf.copy(newBuf, 0, 0, info.dataOffset);
|
||||
|
||||
// Copy PCM data from inSample to outSample
|
||||
const srcStart = info.dataOffset + inSample * frameSize;
|
||||
const srcEnd = info.dataOffset + outSample * frameSize;
|
||||
buf.copy(newBuf, info.dataOffset, srcStart, srcEnd);
|
||||
|
||||
// Apply 5ms cosine crossfade at the cut points to prevent clicks
|
||||
const fadeSamples = Math.min(Math.floor(info.sampleRate * 0.005), Math.floor(croppedSamples / 4));
|
||||
|
||||
if (fadeSamples > 0) {
|
||||
// Fade-in at the start of the cropped region
|
||||
for (let i = 0; i < fadeSamples; i++) {
|
||||
const gain = 0.5 * (1 - Math.cos(Math.PI * i / fadeSamples)); // 0 → 1
|
||||
for (let ch = 0; ch < info.numChannels; ch++) {
|
||||
const off = info.dataOffset + i * frameSize + ch * bytesPerSample;
|
||||
if (off + bytesPerSample > newBuf.length) continue;
|
||||
applySampleGain(newBuf, off, info.bitsPerSample, gain);
|
||||
}
|
||||
}
|
||||
|
||||
// Fade-out at the end of the cropped region
|
||||
for (let i = 0; i < fadeSamples; i++) {
|
||||
const sampleIdx = croppedSamples - fadeSamples + i;
|
||||
const gain = 0.5 * (1 + Math.cos(Math.PI * i / fadeSamples)); // 1 → 0
|
||||
for (let ch = 0; ch < info.numChannels; ch++) {
|
||||
const off = info.dataOffset + sampleIdx * frameSize + ch * bytesPerSample;
|
||||
if (off + bytesPerSample > newBuf.length) continue;
|
||||
applySampleGain(newBuf, off, info.bitsPerSample, gain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update RIFF chunk size (file size - 8)
|
||||
newBuf.writeUInt32LE(newBuf.length - 8, 4);
|
||||
|
||||
// Update data chunk size
|
||||
newBuf.writeUInt32LE(newDataSize, info.dataOffset - 4);
|
||||
|
||||
// Write back
|
||||
fs.writeFileSync(wavPath, newBuf);
|
||||
|
||||
const newDurationSec = croppedSamples / info.sampleRate;
|
||||
return { newDurationSec };
|
||||
}
|
||||
|
||||
/** Apply a gain multiplier to a single sample at the given buffer offset */
|
||||
function applySampleGain(buf: Buffer, off: number, bitsPerSample: number, gain: number): void {
|
||||
if (bitsPerSample === 16) {
|
||||
const val = buf.readInt16LE(off);
|
||||
buf.writeInt16LE(Math.round(val * gain), off);
|
||||
} else if (bitsPerSample === 32) {
|
||||
const val = buf.readFloatLE(off);
|
||||
buf.writeFloatLE(val * gain, off);
|
||||
} else if (bitsPerSample === 24) {
|
||||
const raw = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16);
|
||||
let val = raw > 0x7FFFFF ? raw - 0x1000000 : raw;
|
||||
val = Math.round(val * gain);
|
||||
buf[off] = val & 0xFF;
|
||||
buf[off + 1] = (val >> 8) & 0xFF;
|
||||
buf[off + 2] = (val >> 16) & 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
// ── LRC crop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crop an LRC file in place: remove lines outside [inPointSec, outPointSec],
|
||||
* then shift all remaining timestamps by -inPointSec.
|
||||
*/
|
||||
export function cropLrcFile(
|
||||
lrcPath: string,
|
||||
inPointSec: number,
|
||||
outPointSec: number,
|
||||
): void {
|
||||
const raw = fs.readFileSync(lrcPath, 'utf-8');
|
||||
const lines = raw.replace(/\r/g, '').split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\[(\d+):(\d+)(?:\.(\d+))?\]\s*(.*)$/);
|
||||
if (!match) {
|
||||
// Non-timestamped line (metadata like [ti:], [ar:], etc.) — keep as-is
|
||||
if (line.trim().length > 0) result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const mins = parseInt(match[1], 10);
|
||||
const secs = parseInt(match[2], 10);
|
||||
const cs = match[3] ? parseInt(match[3].padEnd(2, '0').slice(0, 2), 10) : 0;
|
||||
const text = match[4];
|
||||
const time = mins * 60 + secs + cs / 100;
|
||||
|
||||
// Filter: keep only lines within the crop range
|
||||
if (time < inPointSec || time > outPointSec) continue;
|
||||
|
||||
// Shift timestamp by -inPointSec
|
||||
const newTime = Math.max(0, time - inPointSec);
|
||||
const newMins = Math.floor(newTime / 60);
|
||||
const newSecs = Math.floor(newTime % 60);
|
||||
const newCs = Math.round((newTime - Math.floor(newTime)) * 100);
|
||||
|
||||
const ts = `[${String(newMins).padStart(2, '0')}:${String(newSecs).padStart(2, '0')}.${String(newCs).padStart(2, '0')}]`;
|
||||
result.push(`${ts} ${text}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(lrcPath, result.join('\n') + '\n', 'utf-8');
|
||||
}
|
||||
Reference in New Issue
Block a user