/**
* RefAudioPlayer — 自定义 Ref Audio 播放器组件
*
* 功能:
* - 播放/暂停 PCM float32 16kHz base64 音频
* - 显示文件名、时长
* - 进度条(可点击跳转)
* - 上传按钮(重采样到 16kHz mono)
* - 重置按钮(恢复默认 ref audio)
* - 支持 light/dark 主题
*
* 用法:
* const player = new RefAudioPlayer(container, {
* theme: 'light', // 'light' | 'dark'
* onUpload(base64, name, duration) { ... },
* onRemove() { ... },
* });
* player.setAudio(base64, name, duration);
*/
/* ── CSS 注入(仅一次) ── */
(function injectCSS() {
if (document.getElementById('ref-audio-player-css')) return;
const style = document.createElement('style');
style.id = 'ref-audio-player-css';
style.textContent = `
.rap-wrap {
border-radius: 8px;
padding: 10px 12px;
transition: all 0.15s;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* ── light theme ── */
.rap-wrap.rap-light {
border: 1px dashed #d5d5d0;
background: #fafaf8;
color: #2d2d2d;
}
.rap-wrap.rap-light.rap-has-audio {
border: 1px solid #d4a574;
background: #faf5f0;
}
.rap-wrap.rap-light .rap-btn {
background: #f0ece6;
border: 1px solid #d5d5d0;
color: #555;
}
.rap-wrap.rap-light .rap-btn:hover { background: #e8e4de; }
.rap-wrap.rap-light .rap-progress-bg { background: #e8e4de; }
.rap-wrap.rap-light .rap-progress-fill { background: #d4a574; }
.rap-wrap.rap-light .rap-dur { color: #999; }
.rap-wrap.rap-light .rap-name { color: #2d2d2d; }
.rap-wrap.rap-light .rap-empty { color: #999; }
/* ── dark theme ── */
.rap-wrap.rap-dark {
border: 1px dashed #444;
background: #1e1e1e;
color: #ddd;
}
.rap-wrap.rap-dark.rap-has-audio {
border: 1px solid #d4a574;
background: #2a2520;
}
.rap-wrap.rap-dark .rap-btn {
background: #2a2a2a;
border: 1px solid #444;
color: #aaa;
}
.rap-wrap.rap-dark .rap-btn:hover { background: #3a3a3a; }
.rap-wrap.rap-dark .rap-progress-bg { background: #333; }
.rap-wrap.rap-dark .rap-progress-fill { background: #d4a574; }
.rap-wrap.rap-dark .rap-dur { color: #888; }
.rap-wrap.rap-dark .rap-name { color: #e0e0e0; }
.rap-wrap.rap-dark .rap-empty { color: #666; }
/* ── 布局 ── */
.rap-row {
display: flex;
align-items: center;
gap: 8px;
}
.rap-play-btn {
width: 28px; height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
font-size: 12px;
transition: all 0.12s;
border: none;
user-select: none;
}
.rap-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rap-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
line-height: 1;
}
.rap-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.rap-dur {
font-size: 11px;
white-space: nowrap;
flex-shrink: 0;
}
.rap-progress-bg {
width: 100%;
height: 4px;
border-radius: 2px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.rap-progress-fill {
height: 100%;
border-radius: 2px;
width: 0%;
transition: width 0.05s linear;
}
.rap-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.rap-btn {
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: all 0.12s;
line-height: 1.2;
}
.rap-btn-remove {
background: transparent !important;
border-color: #cf222e !important;
color: #cf222e !important;
padding: 3px 5px;
}
.rap-btn-remove:hover {
background: rgba(207, 34, 46, 0.1) !important;
}
/* ── empty state ── */
.rap-empty {
text-align: center;
font-size: 12px;
cursor: pointer;
padding: 4px 0;
}
.rap-empty-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* hidden file input */
.rap-file-input { display: none; }
`;
document.head.appendChild(style);
})();
function _rapT(key, fallback) {
return window.I18n?.t?.[key] ?? fallback;
}
class RefAudioPlayer {
/**
* @param {HTMLElement} container
* @param {Object} options
* @param {'light'|'dark'} [options.theme='light']
* @param {Function} [options.onUpload] (base64, name, duration) => void
* @param {Function} [options.onRemove] () => void
*/
constructor(container, options = {}) {
this.container = container;
this.theme = options.theme || 'light';
this.onUpload = options.onUpload || (() => {});
this.onRemove = options.onRemove || (() => {});
// audio state
this._base64 = null;
this._name = '';
this._duration = 0;
// playback state
this._playing = false;
this._audioCtx = null;
this._sourceNode = null;
this._audioBuffer = null;
this._startTime = 0; // audioCtx.currentTime when playback started
this._startOffset = 0; // offset in seconds (for resume after seek)
this._rafId = null;
this._render();
}
/** 设置音频数据 */
setAudio(base64, name, duration) {
this._stop();
this._base64 = base64;
this._name = name || 'audio';
this._duration = duration || 0;
this._audioBuffer = null; // invalidate decoded buffer
this._updateUI();
}
/** 清除 */
clear() {
this._stop();
this._base64 = null;
this._name = '';
this._duration = 0;
this._audioBuffer = null;
this._updateUI();
}
// ── 内部:渲染 ──
_render() {
const wrap = document.createElement('div');
wrap.className = `rap-wrap rap-${this.theme}`;
// file input (hidden)
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'audio/*';
fileInput.className = 'rap-file-input';
fileInput.addEventListener('change', (e) => this._handleUpload(e));
wrap.appendChild(fileInput);
this._fileInput = fileInput;
// player content (filled by _updateUI)
const content = document.createElement('div');
content.className = 'rap-content';
wrap.appendChild(content);
this._contentEl = content;
this.container.innerHTML = '';
this.container.appendChild(wrap);
this._wrapEl = wrap;
this._updateUI();
}
_updateUI() {
if (this._base64) {
this._renderPlayer();
} else {
this._renderEmpty();
}
}
_renderEmpty() {
this._wrapEl.classList.remove('rap-has-audio');
this._contentEl.innerHTML = '';
const row = document.createElement('div');
row.className = 'rap-empty';
row.innerHTML = `
${_rapT('noRefAudio', 'No reference audio')}
${_rapT('upload', 'Upload')}
`;
row.addEventListener('click', () => this._fileInput.click());
this._contentEl.appendChild(row);
}
_renderPlayer() {
this._wrapEl.classList.add('rap-has-audio');
this._contentEl.innerHTML = '';
const row = document.createElement('div');
row.className = 'rap-row';
// play button
const playBtn = document.createElement('button');
playBtn.className = `rap-play-btn rap-btn`;
playBtn.textContent = '▶';
playBtn.title = 'Play / Pause';
playBtn.addEventListener('click', () => this._togglePlay());
row.appendChild(playBtn);
this._playBtn = playBtn;
// info section
const info = document.createElement('div');
info.className = 'rap-info';
// meta: name + duration
const meta = document.createElement('div');
meta.className = 'rap-meta';
const nameEl = document.createElement('span');
nameEl.className = 'rap-name';
nameEl.textContent = this._name;
nameEl.title = this._name;
meta.appendChild(nameEl);
const durEl = document.createElement('span');
durEl.className = 'rap-dur';
durEl.textContent = this._formatTime(this._duration);
meta.appendChild(durEl);
this._durEl = durEl;
info.appendChild(meta);
// progress bar
const progressBg = document.createElement('div');
progressBg.className = 'rap-progress-bg';
progressBg.addEventListener('click', (e) => this._handleSeek(e));
const progressFill = document.createElement('div');
progressFill.className = 'rap-progress-fill';
progressBg.appendChild(progressFill);
info.appendChild(progressBg);
this._progressFill = progressFill;
this._progressBg = progressBg;
row.appendChild(info);
// action buttons
const actions = document.createElement('div');
actions.className = 'rap-actions';
const uploadBtn = document.createElement('span');
uploadBtn.className = 'rap-btn';
uploadBtn.textContent = _rapT('upload', 'Upload');
uploadBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._fileInput.click();
});
actions.appendChild(uploadBtn);
const removeBtn = document.createElement('span');
removeBtn.className = 'rap-btn rap-btn-remove';
removeBtn.textContent = '✕';
removeBtn.title = _rapT('resetToDefault', 'Reset to default');
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._stop();
this.onRemove();
});
actions.appendChild(removeBtn);
row.appendChild(actions);
this._contentEl.appendChild(row);
}
// ── 内部:播放 ──
async _togglePlay() {
if (this._playing) {
this._stop();
return;
}
if (!this._base64) return;
try {
if (!this._audioCtx) {
this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (this._audioCtx.state === 'suspended') {
await this._audioCtx.resume();
}
// decode if needed
if (!this._audioBuffer) {
this._audioBuffer = await this._decodeBase64PCM(this._base64);
}
// create source
this._sourceNode = this._audioCtx.createBufferSource();
this._sourceNode.buffer = this._audioBuffer;
this._sourceNode.connect(this._audioCtx.destination);
this._sourceNode.onended = () => {
if (this._playing) {
this._playing = false;
this._startOffset = 0;
this._updatePlayBtn();
this._setProgress(0);
this._stopRAF();
}
};
this._startTime = this._audioCtx.currentTime;
this._sourceNode.start(0, this._startOffset);
this._playing = true;
this._updatePlayBtn();
this._startRAF();
} catch (e) {
console.error('RefAudioPlayer: playback failed', e);
}
}
_stop() {
if (this._sourceNode) {
try { this._sourceNode.stop(); } catch (_) {}
try { this._sourceNode.disconnect(); } catch (_) {}
this._sourceNode = null;
}
if (this._playing) {
// save current offset for resume
this._startOffset = 0;
}
this._playing = false;
this._updatePlayBtn();
this._setProgress(0);
this._stopRAF();
}
_handleSeek(e) {
if (!this._duration) return;
const rect = this._progressBg.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTo = ratio * this._duration;
if (this._playing) {
// restart from new position
if (this._sourceNode) {
try { this._sourceNode.stop(); } catch (_) {}
try { this._sourceNode.disconnect(); } catch (_) {}
}
this._startOffset = seekTo;
this._playing = false;
this._togglePlay(); // restart
} else {
this._startOffset = seekTo;
this._setProgress(ratio * 100);
}
}
/** Decode PCM float32 16kHz base64 → AudioBuffer (at device sample rate) */
async _decodeBase64PCM(base64) {
// decode base64 → Float32Array (16kHz mono)
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const pcm16k = new Float32Array(bytes.buffer);
// create AudioBuffer at 16kHz
const sr = 16000;
const buf = this._audioCtx.createBuffer(1, pcm16k.length, sr);
buf.getChannelData(0).set(pcm16k);
return buf;
}
// ── 内部:UI 辅助 ──
_updatePlayBtn() {
if (!this._playBtn) return;
this._playBtn.textContent = this._playing ? '⏸' : '▶';
}
_setProgress(pct) {
if (this._progressFill) {
this._progressFill.style.width = Math.min(100, Math.max(0, pct)) + '%';
}
}
_startRAF() {
this._stopRAF();
const tick = () => {
if (!this._playing || !this._audioCtx) return;
const elapsed = (this._audioCtx.currentTime - this._startTime) + this._startOffset;
const pct = this._duration > 0 ? (elapsed / this._duration) * 100 : 0;
this._setProgress(pct);
// update duration display with current time
if (this._durEl) {
this._durEl.textContent = `${this._formatTime(elapsed)} / ${this._formatTime(this._duration)}`;
}
this._rafId = requestAnimationFrame(tick);
};
this._rafId = requestAnimationFrame(tick);
}
_stopRAF() {
if (this._rafId) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
// restore duration display
if (this._durEl && !this._playing) {
this._durEl.textContent = this._formatTime(this._duration);
}
}
_formatTime(sec) {
if (!sec || sec <= 0) return '0.0s';
if (sec < 60) return sec.toFixed(1) + 's';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
// ── 内部:上传处理 ──
async _handleUpload(event) {
const file = event.target.files[0];
if (!file) return;
event.target.value = '';
try {
const arrayBuffer = await file.arrayBuffer();
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
if (decoded.duration > 20) {
const msg = typeof window.I18n?.t?.audioTooLong === 'function'
? window.I18n.t.audioTooLong(decoded.duration.toFixed(1), 20)
: `Audio too long: ${decoded.duration.toFixed(1)}s (max 20s)`;
alert(msg);
audioCtx.close();
return;
}
// 重采样到 16kHz mono → PCM float32 base64
const offlineCtx = new OfflineAudioContext(1, Math.ceil(decoded.duration * 16000), 16000);
const source = offlineCtx.createBufferSource();
source.buffer = decoded;
source.connect(offlineCtx.destination);
source.start();
const resampled = await offlineCtx.startRendering();
audioCtx.close();
const pcm = resampled.getChannelData(0);
const bytes = new Uint8Array(pcm.buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
const base64 = btoa(binary);
// stop current playback
this._stop();
// update state
this._base64 = base64;
this._name = file.name;
this._duration = decoded.duration;
this._audioBuffer = null; // will re-decode on play
this._updateUI();
// notify parent
this.onUpload(base64, file.name, decoded.duration);
} catch (e) {
const msg = typeof window.I18n?.t?.processAudioFailed === 'function'
? window.I18n.t.processAudioFailed(e.message)
: 'Failed to process audio: ' + e.message;
alert(msg);
}
}
}
// Expose to ES Modules (class declarations don't auto-attach to window)
window.RefAudioPlayer = RefAudioPlayer;