/** * 浏览器端音频解析工具 * * 在用户选择音频文件后,立即在前端解析音频元数据 * 用于自动填充上传表单字段 * * @module audio-parser-browser * @created 2025-12-14 */ import * as musicMetadata from 'music-metadata-browser' /** * 音频元数据 */ export interface AudioMetadataBrowser { duration: number // 时长(秒) durationFormatted: string // 格式化时长 (mm:ss) format: string // 音频格式 bitrate?: number // 比特率 sampleRate?: number // 采样率 channels?: number // 声道数 title?: string // 标题(内嵌标签) artist?: string // 艺术家(内嵌标签) album?: string // 专辑(内嵌标签) year?: number // 年份(内嵌标签) genre?: string[] // 流派(内嵌标签) coverImage?: string // 内嵌封面图(base64 data URL) } /** * 检测字符串是否看起来像乱码(GBK被错误解码为ISO-8859-1) * 乱码特征:包含大量高位字符(0x80-0xFF范围) */ function looksLikeGarbledText(str: string | undefined): boolean { if (!str) return false // 统计高位字符(Latin-1 扩展字符)的比例 let highByteCount = 0 for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i) if (code >= 0x80 && code <= 0xFF) { highByteCount++ } } // 如果超过30%的字符是高位字符,很可能是乱码 return highByteCount / str.length > 0.3 } /** * 尝试将被错误解码的字符串(ISO-8859-1)转换回正确的中文(GBK) * 原理:ISO-8859-1 是单字节编码,GBK 是双字节编码 * 当 GBK 编码的字节被当作 ISO-8859-1 解码时,每个字节变成一个字符 * 我们需要将这些字符转回字节,然后用 GBK 重新解码 */ async function tryDecodeAsGBK(str: string | undefined): Promise { if (!str || !looksLikeGarbledText(str)) return str try { // 将字符串转换回字节数组(假设是 ISO-8859-1 编码) const bytes = new Uint8Array(str.length) for (let i = 0; i < str.length; i++) { bytes[i] = str.charCodeAt(i) & 0xFF } // 使用 TextDecoder 以 GBK 编码解码 const decoder = new TextDecoder('gbk') const decoded = decoder.decode(bytes) // 验证解码结果是否合理(应该包含中文字符) const hasChinese = /[\u4e00-\u9fff]/.test(decoded) if (hasChinese) { console.log('[AudioParserBrowser] Successfully decoded GBK text:', { original: str, decoded }) return decoded } return str } catch (error) { console.warn('[AudioParserBrowser] Failed to decode as GBK:', error) return str } } /** * 格式化时长为 mm:ss 格式 */ export function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` } /** * 检查文件是否为音频文件 */ export function isAudioFile(file: File): boolean { if (file.type && file.type.startsWith('audio/')) { return true } const ext = file.name.split('.').pop()?.toLowerCase() || '' const audioExtensions = ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'wma', 'aiff', 'ape'] return audioExtensions.includes(ext) } /** * 支持的音频格式 */ export const AUDIO_ACCEPT_TYPES = 'audio/mpeg,audio/wav,audio/flac,audio/aac,audio/mp4,audio/ogg,audio/webm,.mp3,.wav,.flac,.aac,.m4a,.ogg,.wma' export const AUDIO_FILE_EXTENSIONS = ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'wma', 'aiff', 'ape', 'webm'] /** * 在浏览器端解析音频文件获取元数据 * * @param file - 音频文件 * @returns 音频元数据 */ export async function parseAudioFile(file: File): Promise { try { const metadata = await musicMetadata.parseBlob(file) const duration = metadata.format.duration || 0 // 提取内嵌封面图 let coverImage: string | undefined if (metadata.common.picture && metadata.common.picture.length > 0) { const picture = metadata.common.picture[0] const base64 = btoa( new Uint8Array(picture.data).reduce((data, byte) => data + String.fromCharCode(byte), '') ) coverImage = `data:${picture.format};base64,${base64}` } // 尝试修复可能的 GBK 编码乱码问题 // 中文 Windows 下的 MP3 文件 ID3 标签常使用 GBK 编码 const title = await tryDecodeAsGBK(metadata.common.title) const artist = await tryDecodeAsGBK(metadata.common.artist) const album = await tryDecodeAsGBK(metadata.common.album) // 处理流派(可能是数组) let genre = metadata.common.genre if (genre && genre.length > 0) { const decodedGenre = await Promise.all(genre.map(g => tryDecodeAsGBK(g))) genre = decodedGenre.filter((g): g is string => g !== undefined) } return { duration, durationFormatted: formatDuration(duration), format: metadata.format.container || 'unknown', bitrate: metadata.format.bitrate, sampleRate: metadata.format.sampleRate, channels: metadata.format.numberOfChannels, title, artist, album, year: metadata.common.year, genre, coverImage, } } catch (error) { console.error('[AudioParserBrowser] Failed to parse audio file:', error) return null } }