<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>按住說話演示</title>
<style>
#recordBtn { width: 120px; height: 120px; border-radius: 60px; background: #e74c3c; color: white; font-size: 18px; border: none; cursor: pointer; touch-action: none; }
#recordBtn.recording { background: #27ae60; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }
#status { margin-top: 20px; font-family: monospace; }
audio { display: block; margin: 10px 0; }
</style>
</head>
<body>
<button id="recordBtn">按住說話</button>
<div id="status">就緒</div>
<audio id="playback" controls></audio>
<script>
const btn = document.getElementById('recordBtn');
const status = document.getElementById('status');
const playback = document.getElementById('playback');
let mediaRecorder, chunks = [], startTime;
// 1. 請求麥克風權限
async function initMicrophone() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
mediaRecorder.ondataavailable = e => {
if (e.data.size > 0) chunks.push(e.data);
};
mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
chunks = [];
await uploadAudio(blob);
playback.src = URL.createObjectURL(blob);
};
updateStatus('麥克風就緒,可開始錄音');
} catch (err) {
updateStatus('麥克風權限被拒絕: ' + err.message);
}
}
// 2. 上傳到 PHP 後端
async function uploadAudio(blob) {
updateStatus('上傳中...');
const formData = new FormData();
formData.append('audio', blob, `recording_${Date.now()}.webm`);
try {
const resp = await fetch('upload.php', { method: 'POST', body: formData });
const result = await resp.json();
updateStatus(result.success ? `上傳成功: ${result.path}` : '上傳失敗: ' + result.msg);
} catch (err) {
updateStatus('上傳異常: ' + err.message);
}
}
// 3. 觸摸/鼠標事件綁定
btn.addEventListener('mousedown', startRecord);
btn.addEventListener('touchstart', e => { e.preventDefault(); startRecord(); });
window.addEventListener('mouseup', stopRecord);
window.addEventListener('touchend', stopRecord);
window.addEventListener('mouseleave', stopRecord); // 鼠標移出按鈕也停止
function startRecord() {
if (mediaRecorder && mediaRecorder.state === 'inactive') {
chunks = [];
mediaRecorder.start(100); // 每 100ms 觸發 ondataavailable
startTime = Date.now();
btn.classList.add('recording');
btn.textContent = '釋放結束';
updateStatus('錄音中...');
}
}
function stopRecord() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
btn.classList.remove('recording');
btn.textContent = '按住說話';
updateStatus(`錄音結束,時長 ${((Date.now()-startTime)/1000).toFixed(1)}s`);
}
}
function updateStatus(msg) {
status.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
}
initMicrophone();
</script>
</body>
</html>
<?php
// upload.php
header('Content-Type: application/json');
$uploadDir = __DIR__ . '/uploads/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['audio'])) {
$file = $_FILES['audio'];
if ($file['error'] === UPLOAD_ERR_OK) {
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$newName = 'audio_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$dest = $uploadDir . $newName;
if (move_uploaded_file($file['tmp_name'], $dest)) {
// 可選:存入數據庫
// $pdo->prepare("INSERT INTO recordings (filename, path, created_at) VALUES (?, ?, NOW())")
// ->execute([$file['name'], $dest]);
echo json_encode(['success' => true, 'path' => $dest, 'url' => '/uploads/' . $newName]);
} else {
echo json_encode(['success' => false, 'msg' => '文件移動失敗']);
}
} else {
echo json_encode(['success' => false, 'msg' => '上傳錯誤碼: ' . $file['error']]);
}
} else {
echo json_encode(['success' => false, 'msg' => '無效請求']);
}
?>
| 問題 | 解決方案 |
|---|---|
| iOS Safari 不支援 MediaRecorder | 使用 audio-recorder-polyfill (基於 ScriptProcessorNode) |
| Android Chrome 需 HTTPS | 生產環境必須部署 SSL 證書 |
| 長時間錄音內存暴漲 | 定期 mediaRecorder.stop() + start() 分段,或用 Web Audio API 直接寫入 AudioWorklet |
| 音頻格式轉換 | 前端錄製 WebM/Opus,後端用 FFmpeg 轉 MP3/WAV 存儲 |
// 使用 Web Audio API 獲取實時頻域數據
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function drawWaveform() {
requestAnimationFrame(drawWaveform);
analyser.getByteFrequencyData(dataArray);
// Canvas 繪製 dataArray 柱狀圖...
}
HTML5 「按住說話」核心流程:getUserMedia 獲取流 → MediaRecorder 錄製 → Blob 上傳 → PHP 存儲。關鍵點:HTTPS 部署、iOS 兼容性、長錄音分段、格式轉碼。前端 50 行、後端 30 行即可實現微信級語音體驗。