吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 950|回复: 16
上一主题 下一主题
收起左侧

[讨论] LRC歌词合并、校准工具

[复制链接]
跳转到指定楼层
楼主
lizhipei78 发表于 2026-5-22 10:34 回帖奖励
本帖最后由 lizhipei78 于 2026-5-22 11:01 编辑

想自己制作一首串联歌曲,这个真的太简单了,合并音频的工具非常多,如格式工厂等,但是合并后歌曲歌词却对应不上了,合并歌词的工具好像并没有。基于此小众需求,我特点弄了一个,也加入了对单首歌词的校对调整功能。

使用说明:
一、使用工具1合并多个LRC文件,自动添加#01##02#等标识符,主要是方便后面快速对单首歌进行调整。支持标准LRC格式:[mm:ss.xx][mm:ss:xx]。可设置歌曲间间隔时间,生成带标识符的合并文件。


这里面要特别说明:一般歌词中并没有歌曲的最终时间,所以要手动在每首歌词后添加歌曲的时间,这样子才能保证后面的歌词对得上,如下图所示。

一般按照上面的方法,在歌词中手动添加歌曲时间再进行合并,如果原来的歌词是对的,那么合并后的歌词也是对的。真的是对不上,那么就用下面的工具2进行校对一下,也非常简单。

二、使用工具2:歌词校准器

1.单文件模式:整体偏移,适合调整整首歌的歌词同步,同时支持普通单个LRC文件,可作为普通歌词的校正器。

2.多文件模式:适用于工具1生成的带标识符的LRC文件,可单独校准某一首歌的时间,其他歌曲保持不变逐首校准,精准调整某一首歌。
3.支持撤销/重做:Ctrl+Z/Ctrl+Y,放心尝试。
4.左右对比视图:黄色/绿色高亮显示修改差异

5.支持快捷调整:±0.5秒、±5秒一键调整
6.智能检测加载文件时,系统会自动检测是否包含标识符如果在单文件模式下检测到标识符,会提示切换到多文件模式如果在多文件模式下加载普通文件,会提示切换到单文件模式

打包下载:https://wwasn.lanzout.com/iyjQs3q1uh7a

源码:
[JavaScript] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>LRC歌词套件 - 合并+精准校准工具(支持单文件/多文件)</title>
    <style>
        * {
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            margin: 0;
            padding: 20px;
        }
        .container {
            max-width: 1600px;
            margin: 0 auto;
        }
        .tabs {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }
        .tab-btn {
            flex: 1;
            padding: 15px;
            background: rgba(255,255,255,0.2);
            border: none;
            border-radius: 12px;
            color: white;
            font-size: 18px;
            font-weight: bold;
            cursor: pointer;
            transition: all 0.3s;
        }
        .tab-btn.active {
            background: white;
            color: #667eea;
            box-shadow: 0 5px 20px rgba(0,0,0,0.2);
        }
        .tab-pane {
            display: none;
        }
        .tab-pane.active {
            display: block;
        }
        .card {
            background: white;
            border-radius: 20px;
            overflow: hidden;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
        }
        .card-header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 25px 30px;
        }
        .card-header h2 {
            margin: 0 0 5px 0;
        }
        .card-header p {
            margin: 0;
            opacity: 0.9;
        }
        .card-body {
            padding: 30px;
        }
        .file-section {
            background: #f8f9fa;
            border-radius: 12px;
            padding: 20px;
            margin-bottom: 20px;
        }
        .file-list {
            max-height: 400px;
            overflow-y: auto;
        }
        .file-item {
            background: white;
            border: 1px solid #e0e0e0;
            border-radius: 10px;
            padding: 12px 15px;
            margin-bottom: 10px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-wrap: wrap;
            gap: 10px;
        }
        .file-info {
            display: flex;
            align-items: center;
            gap: 12px;
            flex-wrap: wrap;
        }
        .file-index {
            background: #667eea;
            color: white;
            padding: 4px 12px;
            border-radius: 20px;
            font-size: 12px;
            font-weight: bold;
        }
        .file-name {
            font-weight: bold;
            word-break: break-all;
        }
        .file-duration {
            font-size: 12px;
            color: #666;
            font-family: monospace;
        }
        .btn-group {
            display: flex;
            gap: 8px;
        }
        .btn-sm {
            padding: 5px 12px;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 12px;
        }
        .btn-up { background: #6c757d; color: white; }
        .btn-down { background: #6c757d; color: white; }
        .btn-remove { background: #dc3545; color: white; }
        .btn-add { background: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; margin-right: 10px; }
        .btn-clear { background: #dc3545; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
        .btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; font-size: 16px; }
        .btn-success { background: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
        .btn-info { background: #17a2b8; color: white; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
        .btn-warning { background: #ffc107; color: #333; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
        .setting-row {
            display: flex;
            align-items: center;
            gap: 15px;
            flex-wrap: wrap;
            margin-bottom: 20px;
        }
        .setting-row label {
            font-weight: bold;
        }
        .setting-row input {
            padding: 8px 12px;
            border: 1px solid #ddd;
            border-radius: 6px;
            width: 100px;
        }
        .result-area {
            background: #f8f9fa;
            border-radius: 12px;
            padding: 20px;
            margin-top: 20px;
            display: none;
        }
        .result-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 15px;
            flex-wrap: wrap;
            gap: 10px;
        }
        .result-content {
            background: #2d2d2d;
            color: #f8f8f2;
            border-radius: 8px;
            padding: 15px;
            max-height: 500px;
            overflow-y: auto;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            white-space: pre-wrap;
            word-break: break-all;
        }
        .calibrate-section {
            background: #f8f9fa;
            border-radius: 12px;
            padding: 20px;
            margin-bottom: 20px;
        }
        .calibrate-control {
            display: flex;
            align-items: center;
            gap: 15px;
            flex-wrap: wrap;
            margin-bottom: 20px;
        }
        .calibrate-control input {
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 6px;
            width: 120px;
        }
        .song-select {
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 6px;
            font-size: 14px;
        }
        /* 对比视图样式 */
        .compare-container {
            display: flex;
            gap: 20px;
            margin-top: 20px;
            flex-wrap: wrap;
        }
        .compare-panel {
            flex: 1;
            min-width: 300px;
            background: white;
            border-radius: 12px;
            overflow: hidden;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        .compare-panel h4 {
            margin: 0;
            padding: 12px 15px;
            background: #667eea;
            color: white;
            font-size: 14px;
        }
        .compare-panel .diff-content {
            background: #2d2d2d;
            color: #f8f8f2;
            padding: 15px;
            max-height: 500px;
            overflow-y: auto;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            white-space: pre-wrap;
            word-break: break-all;
        }
        .diff-highlight {
            background: #ffeb3b;
            color: #333;
            display: inline-block;
        }
        .diff-added {
            background: #4caf50;
            color: white;
        }
        .mode-switch {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
            padding: 10px;
            background: #e9ecef;
            border-radius: 10px;
        }
        .mode-btn {
            flex: 1;
            padding: 10px;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-weight: bold;
            background: white;
            color: #667eea;
            transition: all 0.3s;
        }
        .mode-btn.active {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
        }
        .info {
            background: #d1ecf1;
            color: #0c5460;
            padding: 12px;
            border-radius: 8px;
            font-size: 13px;
            margin-bottom: 20px;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 10px;
            border-radius: 8px;
            margin-bottom: 20px;
            display: none;
        }
        .empty-state {
            text-align: center;
            padding: 40px;
            color: #999;
        }
        .badge {
            display: inline-block;
            padding: 2px 8px;
            border-radius: 12px;
            font-size: 11px;
            font-weight: bold;
        }
        .badge-changed {
            background: #ffc107;
            color: #333;
        }
        .undo-redo-bar {
            display: flex;
            gap: 10px;
            margin-bottom: 15px;
            padding: 10px;
            background: #e9ecef;
            border-radius: 8px;
            align-items: center;
            flex-wrap: wrap;
        }
        .undo-redo-bar span {
            font-size: 12px;
            color: #666;
            margin-left: auto;
        }
        .file-mode-info {
            background: #e7f3ff;
            padding: 10px 15px;
            border-radius: 8px;
            margin-bottom: 15px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-wrap: wrap;
            gap: 10px;
        }
        footer {
            text-align: center;
            padding: 20px;
            color: rgba(255,255,255,0.7);
            font-size: 12px;
        }
        [url=home.php?mod=space&uid=945662]@media[/url] (max-width: 768px) {
            .compare-container {
                flex-direction: column;
            }
            .file-item {
                flex-direction: column;
                align-items: flex-start;
            }
            .calibrate-control {
                flex-direction: column;
                align-items: stretch;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="tabs">
            <button class="tab-btn active">&#128279; 工具1:合并歌词(添加标识符)</button>
            <button class="tab-btn">&#9889; 工具2:歌词校准器(支持单文件/多文件)</button>
        </div>

        <!-- 工具1:合并工具 -->
        <div id="mergeTab" class="tab-pane active">
            <div class="card">
                <div class="card-header">
                    <h2>&#127925; 多LRC歌词合并工具</h2>
                    <p>合并多个LRC文件,自动添加标识符 #01#、#02# 等,用于后续精准校准</p>
                </div>
                <div class="card-body">
                    <div id="mergeError" class="error"></div>
                    
                    <div class="file-section">
                        <div style="margin-bottom: 15px;">
                            <button class="btn-add" id="addFileBtn">&#10133; 添加LRC文件</button>
                            <button class="btn-clear" id="clearAllBtn">&#128465;&#65039; 清空全部</button>
                        </div>
                        <div id="fileListContainer" class="file-list">
                            <div class="empty-state">&#128194; 暂无文件,点击上方按钮添加LRC歌词文件</div>
                        </div>
                    </div>
                    
                    <div class="setting-row">
                        <label>&#9201;&#65039; 歌曲间间隔:</label>
                        <input type="number" id="gapSeconds" value="0" step="0.1" min="0">
                        <span class="hint">秒(每两首歌之间的静音间隔)</span>
                    </div>
                    
                    <div style="display: flex; gap: 15px; flex-wrap: wrap; margin-top: 20px;">
                        <button class="btn-primary" id="mergeBtn">&#128279; 合并并添加标识符</button>
                    </div>
                    
                    <div class="result-area" id="mergeResultArea">
                        <div class="result-header">
                            <h3>&#128196; 合并结果(含标识符)</h3>
                            <button class="btn-success" id="downloadMergeBtn">&#128190; 下载LRC文件</button>
                        </div>
                        <div class="result-content" id="mergeResultContent"></div>
                    </div>
                    
                    <div class="info">
                        &#128161; 说明:<br>
                        1. 合并后的歌词会自动添加标识符行,格式为 <code>#01#</code>、<code>#02#</code> 等<br>
                        2. 这些标识符标记每首歌的开始位置,供工具2的多文件校准模式使用<br>
                        3. 标识符不会影响歌词显示,播放器会忽略无法解析的行<br>
                         <h3>&#128161; 特别说明:一般歌词中并没有歌曲的最终时间,所以要手动在每首歌词后添加歌曲的时间,这样子才能保证后面的歌词对得上</h3>
                    </div>
                </div>
            </div>
        </div>

        <!-- 工具2:校准工具(支持单文件/多文件) -->
        <div id="calibrateTab" class="tab-pane">
            <div class="card">
                <div class="card-header">
                    <h2>&#9889; 歌词校准器</h2>
                    <p>支持单文件模式(校准单个LRC)和多文件模式(校准带#01#标识符的合并文件)| 支持撤销/重做</p>
                </div>
                <div class="card-body">
                    <div id="calibrateError" class="error"></div>
                    
                    <!-- 模式切换 -->
                    <div class="mode-switch">
                        <button class="mode-btn active" id="singleModeBtn">&#128196; 单文件模式</button>
                        <button class="mode-btn" id="multiModeBtn">&#127925; 多文件模式(标识符)</button>
                    </div>
                    
                    <div class="calibrate-section">
                        <div style="margin-bottom: 15px;">
                            <label style="font-weight: bold;">&#128194; 选择LRC文件:</label>
                            <input type="file" id="lrcFileInput" accept=".lrc,.txt" style="display:block; margin-top: 10px;">
                        </div>
                        
                        <!-- 单文件模式信息 -->
                        <div id="singleModeInfo" class="file-mode-info">
                            <span>&#128196; 单文件模式:直接对整个LRC文件进行时间偏移校准</span>
                            <span class="badge" style="background:#17a2b8; color:white;">整体偏移</span>
                        </div>
                        
                        <!-- 多文件模式信息 -->
                        <div id="multiModeInfo" class="file-mode-info" style="display: none;">
                            <span>&#127925; 多文件模式:检测到 <span id="songCount">0</span> 首歌,可选择单首校准</span>
                            <span class="badge" style="background:#ffc107; color:#333;">逐首偏移</span>
                        </div>
                        
                        <!-- 撤销/重做工具栏 -->
                        <div class="undo-redo-bar">
                            <button class="btn-sm" id="undoBtn" style="background:#6c757d; color:white;" disabled>&#8617;&#65039; 撤销 (Ctrl+Z)</button>
                            <button class="btn-sm" id="redoBtn" style="background:#6c757d; color:white;" disabled>&#8618;&#65039; 重做 (Ctrl+Y)</button>
                            <span id="historyStatus">&#128221; 暂无操作历史</span>
                        </div>
                        
                        <!-- 单文件模式控制 -->
                        <div id="singleControls" class="calibrate-control">
                            <label>&#9201;&#65039; 整体时间偏移:</label>
                            <input type="number" id="singleOffsetValue" value="0" step="0.1" placeholder="秒数">
                            <span class="hint">正数=向后推迟,负数=向前提前</span>
                        </div>
                        
                        <!-- 多文件模式控制 -->
                        <div id="multiControls" class="calibrate-control" style="display: none;">
                            <label>&#127925; 选择要校准的歌曲:</label>
                            <select id="songSelect" class="song-select" style="min-width: 150px;">
                                <option value="">请先加载LRC文件</option>
                            </select>
                            
                            <label>&#9201;&#65039; 时间偏移:</label>
                            <input type="number" id="multiOffsetValue" value="0" step="0.1" placeholder="秒数">
                        </div>
                        
                        <div class="calibrate-control">
                            <button class="btn-info" id="previewBtn">&#128065;&#65039; 预览并对比</button>
                            <button class="btn-primary" id="applyOffsetBtn">&#9989; 应用偏移</button>
                        </div>
                        
                        <div class="calibrate-control">
                            <label>&#9889; 快捷调整:</label>
                            <button class="btn-sm" style="background:#17a2b8; color:white;">+0.5秒</button>
                            <button class="btn-sm" style="background:#17a2b8; color:white;">-0.5秒</button>
                            <button class="btn-sm" style="background:#17a2b8; color:white;">+5秒</button>
                            <button class="btn-sm" style="background:#17a2b8; color:white;">-5秒</button>
                            <button class="btn-sm" style="background:#6c757d; color:white;">重置为0</button>
                        </div>
                    </div>
                    
                    <!-- 左右对比视图 -->
                    <div id="compareView" style="display: none;">
                        <div class="compare-container">
                            <div class="compare-panel">
                                <h4>&#128220; 修改前(原始歌词)</h4>
                                <div class="diff-content" id="originalContent"></div>
                            </div>
                            <div class="compare-panel">
                                <h4>&#10024; 修改后(校准后)<span id="changeBadge" class="badge" style="margin-left: 10px;"></span></h4>
                                <div class="diff-content" id="modifiedContent"></div>
                            </div>
                        </div>
                        <div style="text-align: center; margin-top: 15px;">
                            <button class="btn-success" id="downloadCalibrateBtn">&#128190; 下载校准后的LRC文件</button>
                        </div>
                    </div>
                    
                    <div class="info">
                        &#128161; 使用说明:<br>
                        <strong>单文件模式:</strong>加载任意LRC文件,对整个歌词文件进行时间偏移校准(适合调整整首歌的歌词同步问题)<br>
                        <strong>多文件模式:</strong>加载工具1生成的带标识符的LRC文件,可单独校准某一首歌的时间<br>
                        <strong>撤销/重做:</strong>每次应用偏移都会保存历史,支持 Ctrl+Z 撤销 / Ctrl+Y 重做<br>
                        <strong>对比视图:</strong>左右窗口实时显示修改前后的差异,黄色/绿色高亮显示变化
                    </div>
                </div>
            </div>
        </div>
        
        <footer>
            纯本地运行,你的文件不会被上传到任何服务器 | 支持标准LRC格式 [mm:ss.xx] 和 [mm:ss:xx]
        </footer>
    </div>

    <script>
        // ==================== 工具1:合并工具 ====================
        let songsList = [];
        
        function parseTimeToSeconds(timeStr) {
            const match = timeStr.match(/\[(\d+):(\d+)(?:[.:](\d+))?\]/);
            if (match) {
                const minutes = parseInt(match[1]);
                const seconds = parseInt(match[2]);
                const centiseconds = match[3] ? parseInt(match[3]) : 0;
                let fractional = centiseconds >= 100 ? centiseconds / 1000 : centiseconds / 100;
                return minutes * 60 + seconds + fractional;
            }
            return null;
        }
        
        function formatSecondsToLrcTime(seconds) {
            const minutes = Math.floor(seconds / 60);
            const secs = (seconds % 60).toFixed(2);
            const secsStr = secs.padStart(5, '0');
            return `[${minutes.toString().padStart(2, '0')}:${secsStr.replace('.', ':')}]`;
        }
        
        function parseLrc(content) {
            const lines = content.split(/\r?\n/);
            const lyrics = [];
            let maxTime = 0;
            for (let line of lines) {
                line = line.trim();
                if (line === '') continue;
                const timePattern = /\[\d+:\d+(?:[.:]\d+)?\]/g;
                const timeTags = line.match(timePattern);
                if (timeTags) {
                    let text = line;
                    for (const tag of timeTags) text = text.replace(tag, '');
                    text = text.trim();
                    for (const tag of timeTags) {
                        const seconds = parseTimeToSeconds(tag);
                        if (seconds !== null) {
                            if (seconds > maxTime) maxTime = seconds;
                            lyrics.push({ timeTag: tag, seconds: seconds, text: text });
                        }
                    }
                } else {
                    lyrics.push({ timeTag: null, seconds: null, text: line, isMetadata: true });
                }
            }
            return { lyrics, maxTime };
        }
        
        function shiftLyrics(lyrics, offsetSeconds) {
            const shifted = [];
            for (const item of lyrics) {
                if (item.isMetadata || item.seconds === null) {
                    shifted.push(item);
                } else {
                    const newSeconds = item.seconds + offsetSeconds;
                    shifted.push({ timeTag: formatSecondsToLrcTime(newSeconds), seconds: newSeconds, text: item.text });
                }
            }
            return shifted;
        }
        
        function lyricsToLines(lyrics) {
            const lines = [];
            for (const item of lyrics) {
                if (item.isMetadata) lines.push(item.text);
                else lines.push(`${item.timeTag}${item.text}`);
            }
            return lines;
        }
        
        async function readFileContent(file) {
            return new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onload = e => resolve(e.target.result);
                reader.onerror = e => reject(e);
                reader.readAsText(file, 'UTF-8');
            });
        }
        
        async function addFiles(files) {
            const filesArray = Array.from(files);
            for (const file of filesArray) {
                if (!file.name.match(/\.(lrc|txt)$/i)) {
                    showMergeError(`文件 "${file.name}" 不是LRC/TXT格式,已跳过`);
                    continue;
                }
                try {
                    const content = await readFileContent(file);
                    const { maxTime } = parseLrc(content);
                    songsList.push({ file, content, name: file.name, duration: maxTime });
                } catch (err) {
                    showMergeError(`读取失败: ${err.message}`);
                }
            }
            renderFileList();
        }
        
        function removeFile(index) { songsList.splice(index, 1); renderFileList(); }
        function moveUp(index) { if (index > 0) { [songsList[index-1], songsList[index]] = [songsList[index], songsList[index-1]]; renderFileList(); } }
        function moveDown(index) { if (index < songsList.length-1) { [songsList[index], songsList[index+1]] = [songsList[index+1], songsList[index]]; renderFileList(); } }
        
        function renderFileList() {
            const container = document.getElementById('fileListContainer');
            if (songsList.length === 0) {
                container.innerHTML = '<div class="empty-state">&#128194; 暂无文件,点击上方按钮添加LRC歌词文件</div>';
                return;
            }
            let html = '';
            for (let i = 0; i < songsList.length; i++) {
                const song = songsList[i];
                const durationStr = formatSecondsToLrcTime(song.duration);
                html += `
                    <div class="file-item">
                        <div class="file-info">
                            <span class="file-index">#${i+1}</span>
                            <span class="file-name">&#128196; ${escapeHtml(song.name)}</span>
                            <span class="file-duration">时长: ${durationStr}</span>
                        </div>
                        <div class="btn-group">
                            <button class="btn-sm btn-up" ${i===0?'disabled':''}>▲上移</button>
                            <button class="btn-sm btn-down" ${i===songsList.length-1?'disabled':''}>▼下移</button>
                            <button class="btn-sm btn-remove">移除</button>
                        </div>
                    </div>
                `;
            }
            container.innerHTML = html;
        }
        
        function mergeWithMarkers() {
            if (songsList.length === 0) { showMergeError('请先添加LRC文件'); return null; }
            const gapSeconds = parseFloat(document.getElementById('gapSeconds').value) || 0;
            let currentOffset = 0;
            let allLines = [];
            let totalDuration = 0;
            
            for (let i = 0; i < songsList.length; i++) {
                const song = songsList[i];
                const { lyrics } = parseLrc(song.content);
                const shifted = shiftLyrics(lyrics, currentOffset);
                const lines = lyricsToLines(shifted);
                
                const marker = `#${String(i+1).padStart(2, '0')}#`;
                const markerTime = formatSecondsToLrcTime(currentOffset);
                allLines.push(`${markerTime} ${marker}`);
                allLines.push(...lines);
                
                currentOffset += song.duration + gapSeconds;
                totalDuration = currentOffset - gapSeconds;
            }
            
            const timed = [], other = [];
            for (const line of allLines) {
                const match = line.match(/^\[\d+:\d+(?:[.:]\d+)?\]/);
                if (match) {
                    const sec = parseTimeToSeconds(match[0]);
                    if (sec !== null) timed.push({ line, seconds: sec });
                    else other.push(line);
                } else other.push(line);
            }
            timed.sort((a,b) => a.seconds - b.seconds);
            const finalLines = [...other, ...timed.map(t => t.line)];
            const header = [`[ti:合并歌词 (${songsList.length}首)]`, `[by:LRC合并工具]`, `[total:${formatSecondsToLrcTime(totalDuration)}]`, ``];
            return header.join('\n') + '\n' + finalLines.join('\n');
        }
        
        function showMergeError(msg, type = 'error') {
            const div = document.getElementById('mergeError');
            div.style.background = type === 'success' ? '#d4edda' : '#f8d7da';
            div.style.color = type === 'success' ? '#155724' : '#721c24';
            div.textContent = msg;
            div.style.display = 'block';
            setTimeout(() => div.style.display = 'none', 3000);
        }
        
        function escapeHtml(str) { return str.replace(/[&<>]/g, m => ({'&':'&','<':'<','>':'>'}[m])); }
        
        // 工具1事件绑定
        document.getElementById('addFileBtn')?.addEventListener('click', () => {
            const input = document.createElement('input');
            input.type = 'file'; input.accept = '.lrc,.txt'; input.multiple = true;
            input.onchange = async e => { if(e.target.files.length) await addFiles(e.target.files); };
            input.click();
        });
        document.getElementById('clearAllBtn')?.addEventListener('click', () => {
            if(songsList.length && confirm('清空全部?')) { songsList = []; renderFileList(); document.getElementById('mergeResultArea').style.display = 'none'; }
        });
        document.getElementById('mergeBtn')?.addEventListener('click', () => {
            const result = mergeWithMarkers();
            if(result) {
                document.getElementById('mergeResultArea').style.display = 'block';
                document.getElementById('mergeResultContent').textContent = result;
                window.mergedResult = result;
                showMergeError('合并完成!可下载文件', 'success');
            }
        });
        document.getElementById('downloadMergeBtn')?.addEventListener('click', () => {
            if(window.mergedResult) {
                const blob = new Blob([window.mergedResult], {type:'text/plain'});
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url; a.download = `merged_${Date.now()}.lrc`;
                a.click(); URL.revokeObjectURL(url);
            } else showMergeError('请先合并');
        });
        
        window.moveUp = moveUp; window.moveDown = moveDown; window.removeFile = removeFile;
        
        // ==================== 工具2:校准工具(支持单文件/多文件) ====================
        let currentMode = 'single'; // 'single' 或 'multi'
        let originalLrcContent = null;
        let parsedSongs = [];
        let currentCalibratedContent = null;
        
        // 历史记录栈
        let historyStack = [];
        let historyIndex = -1;
        let maxHistorySize = 50;
        
        function saveToHistory(description) {
            if (!originalLrcContent) return;
            if (historyIndex < historyStack.length - 1) {
                historyStack = historyStack.slice(0, historyIndex + 1);
            }
            historyStack.push({
                content: originalLrcContent,
                description: description,
                timestamp: new Date().toLocaleTimeString()
            });
            if (historyStack.length > maxHistorySize) {
                historyStack.shift();
            } else {
                historyIndex = historyStack.length - 1;
            }
            updateHistoryUI();
        }
        
        function undo() {
            if (historyIndex > 0) {
                historyIndex--;
                const state = historyStack[historyIndex];
                originalLrcContent = state.content;
                currentCalibratedContent = state.content;
                
                if (currentMode === 'multi') {
                    const { songs } = parseMarkedLrc(originalLrcContent);
                    parsedSongs = songs;
                    updateSongSelect();
                }
                refreshCompareView();
                showCalibrateError(`&#8617;&#65039; 撤销: ${state.description}`, 'success');
                updateHistoryUI();
            } else {
                showCalibrateError('没有更早的操作记录了', 'error');
            }
        }
        
        function redo() {
            if (historyIndex < historyStack.length - 1) {
                historyIndex++;
                const state = historyStack[historyIndex];
                originalLrcContent = state.content;
                currentCalibratedContent = state.content;
                
                if (currentMode === 'multi') {
                    const { songs } = parseMarkedLrc(originalLrcContent);
                    parsedSongs = songs;
                    updateSongSelect();
                }
                refreshCompareView();
                showCalibrateError(`&#8618;&#65039; 重做: ${state.description}`, 'success');
                updateHistoryUI();
            } else {
                showCalibrateError('没有更晚的操作记录了', 'error');
            }
        }
        
        function updateHistoryUI() {
            const undoBtn = document.getElementById('undoBtn');
            const redoBtn = document.getElementById('redoBtn');
            const historyStatus = document.getElementById('historyStatus');
            
            undoBtn.disabled = (historyIndex <= 0);
            redoBtn.disabled = (historyIndex >= historyStack.length - 1);
            
            if (historyStack.length === 0) {
                historyStatus.innerHTML = '&#128221; 暂无操作历史';
            } else {
                const current = historyStack[historyIndex];
                historyStatus.innerHTML = `&#128221; 当前: ${current.description} (${current.timestamp}) | 共 ${historyStack.length} 条记录`;
            }
        }
        
        // 对整个LRC文件进行偏移(单文件模式)
        function shiftWholeLrc(content, offsetSeconds) {
            const { lyrics } = parseLrc(content);
            const shifted = shiftLyrics(lyrics, offsetSeconds);
            const lines = lyricsToLines(shifted);
            
            // 保留头部元数据(如果有)
            const header = [];
            for (const line of content.split('\n')) {
                if (line.match(/^\[(ti|ar|al|by|offset|total|ve)/i)) {
                    header.push(line);
                } else {
                    break;
                }
            }
            
            const result = [...new Set(header), ...lines].join('\n');
            return { modified: result, changes: [{ description: `整体偏移 ${offsetSeconds}秒` }] };
        }
        
        // 多文件模式:解析带标识符的LRC
        function parseMarkedLrc(content) {
            const lines = content.split(/\r?\n/);
            const songs = [];
            let currentSong = null;
            
            for (let i = 0; i < lines.length; i++) {
                const line = lines[i];
                const markerMatch = line.match(/#(\d+)#/);
                if (markerMatch) {
                    if (currentSong) {
                        currentSong.endLine = i - 1;
                        songs.push(currentSong);
                    }
                    const num = parseInt(markerMatch[1]);
                    const timeMatch = line.match(/^(\[[\d:.]+])/);
                    const startTime = timeMatch ? parseTimeToSeconds(timeMatch[1]) : 0;
                    currentSong = {
                        index: num,
                        startLine: i,
                        endLine: -1,
                        startTime: startTime,
                        identifierLine: line,
                        identifierTimeTag: timeMatch ? timeMatch[1] : null
                    };
                }
            }
            if (currentSong) {
                currentSong.endLine = lines.length - 1;
                songs.push(currentSong);
            }
            
            return { songs, lines };
        }
        
        // 多文件模式:对指定歌曲进行偏移
        function applyOffsetToSong(content, songIndex, offsetSeconds) {
            const { songs, lines } = parseMarkedLrc(content);
            const targetSong = songs.find(s => s.index === songIndex);
            if (!targetSong) return { modified: content, changes: [] };
            
            const newLines = [...lines];
            const changes = [];
            
            // 调整标识符行
            const identifierLine = newLines[targetSong.startLine];
            const idTimeMatch = identifierLine.match(/^(\[[\d:.]+])/);
            if (idTimeMatch && targetSong.identifierTimeTag) {
                const oldTimeTag = idTimeMatch[1];
                const oldTime = parseTimeToSeconds(oldTimeTag);
                if (oldTime !== null) {
                    const newTime = formatSecondsToLrcTime(oldTime + offsetSeconds);
                    newLines[targetSong.startLine] = identifierLine.replace(oldTimeTag, newTime);
                    changes.push({ line: targetSong.startLine, old: oldTimeTag, new: newTime });
                }
            }
            
            // 调整歌词行
            for (let i = targetSong.startLine + 1; i < (targetSong.endLine + 1) && i < newLines.length; i++) {
                let line = newLines[i];
                const timePattern = /\[\d+:\d+(?:[.:]\d+)?\]/g;
                const times = line.match(timePattern);
                if (times) {
                    let modifiedLine = line;
                    for (const timeTag of times) {
                        const oldSec = parseTimeToSeconds(timeTag);
                        if (oldSec !== null) {
                            const newSec = oldSec + offsetSeconds;
                            const newTag = formatSecondsToLrcTime(newSec);
                            modifiedLine = modifiedLine.replace(timeTag, newTag);
                            changes.push({ line: i, old: timeTag, new: newTag });
                        }
                    }
                    newLines[i] = modifiedLine;
                }
            }
            
            return { modified: newLines.join('\n'), changes };
        }
        
        function updateSongSelect() {
            const songSelect = document.getElementById('songSelect');
            if (parsedSongs.length > 0) {
                const currentValue = songSelect.value;
                songSelect.innerHTML = parsedSongs.map(s => `<option value="${s.index}">&#127925; 第 ${s.index} 首歌(标识符 #${String(s.index).padStart(2,'0')}#)</option>`).join('');
                if (currentValue && parsedSongs.some(s => s.index == currentValue)) {
                    songSelect.value = currentValue;
                }
                document.getElementById('songCount').innerHTML = parsedSongs.length;
            }
        }
        
        function refreshCompareView() {
            if (originalLrcContent) {
                const { originalHtml } = generateDiffHtml(originalLrcContent, originalLrcContent, []);
                document.getElementById('originalContent').innerHTML = originalHtml;
                document.getElementById('modifiedContent').innerHTML = originalHtml;
                document.getElementById('compareView').style.display = 'block';
                document.getElementById('changeBadge').innerHTML = '&#128202; 未修改';
            }
        }
        
        function generateDiffHtml(original, modified, changes) {
            if (!original || !modified) return { originalHtml: '', modifiedHtml: '' };
            
            const originalLines = original.split(/\r?\n/);
            const modifiedLines = modified.split(/\r?\n/);
            const maxLines = Math.max(originalLines.length, modifiedLines.length);
            
            let originalHtml = '';
            let modifiedHtml = '';
            
            const changeMap = new Map();
            for (const change of changes) {
                if (change.line !== undefined) {
                    if (!changeMap.has(change.line)) changeMap.set(change.line, []);
                    changeMap.get(change.line).push(change);
                }
            }
            
            for (let i = 0; i < maxLines; i++) {
                const origLine = originalLines[i] || '';
                const modLine = modifiedLines[i] || '';
                const lineChanges = changeMap.get(i) || [];
                
                let origDisplay = escapeHtml(origLine);
                let modDisplay = escapeHtml(modLine);
                
                for (const change of lineChanges) {
                    const oldEscaped = escapeHtml(change.old);
                    const newEscaped = escapeHtml(change.new);
                    origDisplay = origDisplay.replace(oldEscaped, `<span class="diff-highlight">${oldEscaped}</span>`);
                    modDisplay = modDisplay.replace(newEscaped, `<span class="diff-added">${newEscaped}</span>`);
                }
                
                originalHtml += `<div><span style="color: #888; display: inline-block; width: 40px;">${i+1}</span> ${origDisplay}</div>`;
                modifiedHtml += `<div><span style="color: #888; display: inline-block; width: 40px;">${i+1}</span> ${modDisplay}</div>`;
            }
            
            return { originalHtml, modifiedHtml };
        }
        
        function previewAndCompare() {
            if (!originalLrcContent) { showCalibrateError('请先加载LRC文件'); return; }
            
            let modified, changes;
            if (currentMode === 'single') {
                const offset = parseFloat(document.getElementById('singleOffsetValue').value) || 0;
                const result = shiftWholeLrc(originalLrcContent, offset);
                modified = result.modified;
                changes = result.changes;
            } else {
                const songIndex = parseInt(document.getElementById('songSelect').value);
                const offset = parseFloat(document.getElementById('multiOffsetValue').value) || 0;
                if (isNaN(songIndex)) { showCalibrateError('请选择要校准的歌曲'); return; }
                const result = applyOffsetToSong(originalLrcContent, songIndex, offset);
                modified = result.modified;
                changes = result.changes;
            }
            
            currentCalibratedContent = modified;
            const { originalHtml, modifiedHtml } = generateDiffHtml(originalLrcContent, modified, changes);
            
            document.getElementById('originalContent').innerHTML = originalHtml;
            document.getElementById('modifiedContent').innerHTML = modifiedHtml;
            document.getElementById('compareView').style.display = 'block';
            
            const changeCount = changes.length;
            const badge = document.getElementById('changeBadge');
            badge.innerHTML = changeCount > 0 ? `&#128202; 将修改 ${changeCount} 处时间标签` : '无变化';
        }
        
        function applyAndSave() {
            if (!originalLrcContent) { showCalibrateError('请先加载LRC文件'); return; }
            
            let modified, changes, description;
            if (currentMode === 'single') {
                const offset = parseFloat(document.getElementById('singleOffsetValue').value) || 0;
                const result = shiftWholeLrc(originalLrcContent, offset);
                modified = result.modified;
                changes = result.changes;
                description = `整体偏移 ${offset >= 0 ? '+' : ''}${offset}秒`;
            } else {
                const songIndex = parseInt(document.getElementById('songSelect').value);
                const offset = parseFloat(document.getElementById('multiOffsetValue').value) || 0;
                if (isNaN(songIndex)) { showCalibrateError('请选择要校准的歌曲'); return; }
                const result = applyOffsetToSong(originalLrcContent, songIndex, offset);
                modified = result.modified;
                changes = result.changes;
                description = `第${songIndex}首歌偏移 ${offset >= 0 ? '+' : ''}${offset}秒`;
            }
            
            saveToHistory(description);
            originalLrcContent = modified;
            currentCalibratedContent = modified;
            
            if (currentMode === 'multi') {
                const { songs } = parseMarkedLrc(originalLrcContent);
                parsedSongs = songs;
                updateSongSelect();
            }
            
            const { originalHtml, modifiedHtml } = generateDiffHtml(originalLrcContent, originalLrcContent, []);
            document.getElementById('originalContent').innerHTML = originalHtml;
            document.getElementById('modifiedContent').innerHTML = modifiedHtml;
            
            showCalibrateError(`&#9989; 已应用: ${description},修改 ${changes.length} 处`, 'success');
        }
        
        function downloadCalibrated() {
            if (!currentCalibratedContent) { showCalibrateError('请先预览或应用偏移'); return; }
            const blob = new Blob([currentCalibratedContent], {type:'text/plain'});
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url; a.download = `calibrated_${Date.now()}.lrc`;
            a.click(); URL.revokeObjectURL(url);
            showCalibrateError('下载成功!', 'success');
        }
        
        // 文件加载
        document.getElementById('lrcFileInput')?.addEventListener('change', async (e) => {
            const file = e.target.files[0];
            if (!file) return;
            try {
                originalLrcContent = await readFileContent(file);
                currentCalibratedContent = originalLrcContent;
                
                // 检测是否有标识符
                const hasMarkers = /#\d+#/.test(originalLrcContent);
                
                if (hasMarkers && currentMode === 'single') {
                    // 提示用户可以切换到多文件模式
                    showCalibrateError('检测到标识符 #01# 等,建议切换到「多文件模式」进行逐首校准', 'success');
                }
                
                if (currentMode === 'multi') {
                    const { songs } = parseMarkedLrc(originalLrcContent);
                    parsedSongs = songs;
                    if (songs.length === 0) {
                        showCalibrateError('未检测到标识符 #01#、#02# 等,请使用工具1生成或切换到「单文件模式」', 'error');
                    } else {
                        updateSongSelect();
                        document.getElementById('multiModeInfo').style.display = 'flex';
                        document.getElementById('songCount').innerHTML = songs.length;
                    }
                }
                
                // 重置历史
                historyStack = [];
                historyIndex = -1;
                saveToHistory('初始加载');
                
                refreshCompareView();
                showCalibrateError(`加载成功!${currentMode === 'multi' ? `检测到 ${parsedSongs.length} 首歌` : '单文件模式'}`, 'success');
            } catch (err) {
                showCalibrateError('读取失败:' + err.message);
            }
        });
        
        function quickOffset(delta) {
            const offsetInput = currentMode === 'single' ? document.getElementById('singleOffsetValue') : document.getElementById('multiOffsetValue');
            const current = parseFloat(offsetInput.value) || 0;
            offsetInput.value = (current + delta).toFixed(1);
            previewAndCompare();
        }
        
        function resetOffset() {
            const offsetInput = currentMode === 'single' ? document.getElementById('singleOffsetValue') : document.getElementById('multiOffsetValue');
            offsetInput.value = '0';
            previewAndCompare();
        }
        
        function showCalibrateError(msg, type = 'error') {
            const div = document.getElementById('calibrateError');
            div.style.background = type === 'success' ? '#d4edda' : '#f8d7da';
            div.style.color = type === 'success' ? '#155724' : '#721c24';
            div.textContent = msg;
            div.style.display = 'block';
            setTimeout(() => div.style.display = 'none', 3000);
        }
        
        // 模式切换
        function setMode(mode) {
            currentMode = mode;
            const singleModeBtn = document.getElementById('singleModeBtn');
            const multiModeBtn = document.getElementById('multiModeBtn');
            const singleModeInfo = document.getElementById('singleModeInfo');
            const multiModeInfo = document.getElementById('multiModeInfo');
            const singleControls = document.getElementById('singleControls');
            const multiControls = document.getElementById('multiControls');
            
            if (mode === 'single') {
                singleModeBtn.classList.add('active');
                multiModeBtn.classList.remove('active');
                singleModeInfo.style.display = 'flex';
                multiModeInfo.style.display = 'none';
                singleControls.style.display = 'flex';
                multiControls.style.display = 'none';
            } else {
                singleModeBtn.classList.remove('active');
                multiModeBtn.classList.add('active');
                singleModeInfo.style.display = 'none';
                multiModeInfo.style.display = 'flex';
                singleControls.style.display = 'none';
                multiControls.style.display = 'flex';
                
                // 如果已有内容,重新检测标识符
                if (originalLrcContent) {
                    const { songs } = parseMarkedLrc(originalLrcContent);
                    parsedSongs = songs;
                    if (songs.length > 0) {
                        updateSongSelect();
                        document.getElementById('songCount').innerHTML = songs.length;
                        showCalibrateError(`检测到 ${songs.length} 首歌`, 'success');
                    } else {
                        showCalibrateError('当前文件没有标识符,请使用工具1生成或加载带标识符的文件', 'error');
                    }
                }
            }
        }
        
        // 绑定事件
        document.getElementById('previewBtn')?.addEventListener('click', previewAndCompare);
        document.getElementById('applyOffsetBtn')?.addEventListener('click', applyAndSave);
        document.getElementById('downloadCalibrateBtn')?.addEventListener('click', downloadCalibrated);
        document.getElementById('undoBtn')?.addEventListener('click', undo);
        document.getElementById('redoBtn')?.addEventListener('click', redo);
        document.getElementById('singleModeBtn')?.addEventListener('click', () => setMode('single'));
        document.getElementById('multiModeBtn')?.addEventListener('click', () => setMode('multi'));
        
        // 键盘快捷键
        document.addEventListener('keydown', (e) => {
            if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
                e.preventDefault();
                undo();
            }
            if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'Z')) {
                e.preventDefault();
                redo();
            }
        });
        
        window.quickOffset = quickOffset;
        window.resetOffset = resetOffset;
        
        function switchTab(tab) {
            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
            document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
            if (tab === 'merge') {
                document.querySelector('.tab-btn:first-child').classList.add('active');
                document.getElementById('mergeTab').classList.add('active');
            } else {
                document.querySelector('.tab-btn:last-child').classList.add('active');
                document.getElementById('calibrateTab').classList.add('active');
            }
        }
        window.switchTab = switchTab;
    </script>
</body>
</html>

免费评分

参与人数 3吾爱币 +3 热心值 +2 收起 理由
helh0275 + 1 + 1 先评再下后使用,感谢分享!~
qjlfl + 1 + 1 我很赞同!
weidechan + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
mrant 发表于 2026-5-22 12:14
这个工具,这个配色太棒了感谢大佬的分享。
3#
hxy19710830 发表于 2026-5-22 12:50
如果能够根据这个  视频文案提取 https://www.bilibili.com/video/BV1o39fBtEPH/?spm_id_from=333.1391.0.0&vd_source=7fb8517277432816c03a7e90ac0de22e,再修改一下代码就更好了
4#
ssooxp 发表于 2026-5-22 13:45
5#
drawning 发表于 2026-5-22 14:32
支持爱欧迪的歌词转换吗,那个很蛋疼
6#
可坏 发表于 2026-5-22 14:40
能联网自动搜索歌词么
头像被屏蔽
7#
dxxiong 发表于 2026-5-22 14:49
提示: 作者被禁止或删除 内容自动屏蔽
头像被屏蔽
8#
东风破浪 发表于 2026-5-22 15:05
提示: 该帖被管理员或版主屏蔽
9#
 楼主| lizhipei78 发表于 2026-5-22 15:36 |楼主
hxy19710830 发表于 2026-5-22 12:50
如果能够根据这个  视频文案提取 https://www.bilibili.com/video/BV1o39fBtEPH/?spm_id_from=333.1391.0.0 ...

现在的音频转文字确实很厉害了,就是不是离线的,不知道啥时候就不能用了。如果是热门歌曲的话,现在的LRC时间对的也是很齐的,特别是酷狗下载的歌曲,可以用工具把KRC转成LRC,我这个确实是比较麻烦一点,不过不用担心哪天不能用了。
10#
 楼主| lizhipei78 发表于 2026-5-22 15:37 |楼主
ssooxp 发表于 2026-5-22 13:45
居然不支持谷歌或者360浏览器打开,

支持的,你是不是默认的为其它浏览器了
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-17 03:42

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表