/** * 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'); }