1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
164 lines
5.3 KiB
TypeScript
164 lines
5.3 KiB
TypeScript
/**
|
||
* 浏览器端音频解析工具
|
||
*
|
||
* 在用户选择音频文件后,立即在前端解析音频元数据
|
||
* 用于自动填充上传表单字段
|
||
*
|
||
* @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<string | undefined> {
|
||
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<AudioMetadataBrowser | null> {
|
||
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
|
||
}
|
||
}
|