// State
let isRecording = false;
let mediaRecorder = null;
let audioChunks = [];
let audioContext = null;
let processor = null;
let input = null;
const clearUrlQuery = () => {
try {
const cleanUrl = `${window.location.origin}${window.location.pathname}${window.location.hash || ''}`;
window.history.replaceState({}, '', cleanUrl);
} catch {}
};
// UI Elements
const els = {
voiceText: document.getElementById('voice-text'),
btnRecord: document.getElementById('btn-record'),
status: document.getElementById('recording-status'),
btnProcess: document.getElementById('btn-process'),
title: document.getElementById('event-title'),
content: document.getElementById('event-content'),
start: document.getElementById('event-start'),
end: document.getElementById('event-end'),
remind: document.getElementById('event-remind'),
btnSave: document.getElementById('btn-save'),
btnClose: document.getElementById('btn-close'),
overlay: document.getElementById('loading-overlay')
};
// --- Recording Logic ---
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Aliyun requires 16000Hz Sample Rate
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
input = audioContext.createMediaStreamSource(stream);
// Create a ScriptProcessorNode
processor = audioContext.createScriptProcessor(4096, 1, 1);
audioChunks = []; // Store raw PCM data (Float32)
processor.onaudioprocess = (e) => {
if (!isRecording) return;
const inputData = e.inputBuffer.getChannelData(0);
// Clone data because input buffer is reused
audioChunks.push(new Float32Array(inputData));
};
input.connect(processor);
processor.connect(audioContext.destination);
isRecording = true;
updateRecordUI(true);
} catch (err) {
console.error('Microphone access error:', err);
alert('无法访问麦克风,请检查权限设置');
}
}
function stopRecording() {
if (!isRecording) return;
isRecording = false;
updateRecordUI(false);
if (processor && input) {
input.disconnect();
processor.disconnect();
}
if (audioContext) {
audioContext.close();
}
// Process audio data
processAudio();
}
function updateRecordUI(recording) {
if (recording) {
els.btnRecord.innerHTML = '⏹️ 停止录音';
els.btnRecord.classList.replace('btn-primary', 'btn-danger');
els.status.classList.remove('hidden');
} else {
els.btnRecord.innerHTML = '🎤 语音识别';
els.btnRecord.classList.replace('btn-danger', 'btn-primary');
els.status.classList.add('hidden');
}
}
// Convert Float32 PCM to 16-bit PCM
function floatTo16BitPCM(output, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]));
s = s < 0 ? s * 0x8000 : s * 0x7FFF;
output.setInt16(offset, s, true);
}
}
async function processAudio() {
showLoading(true, '正在识别语音...');
try {
// Flatten chunks
const length = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
const buffer = new Float32Array(length);
let offset = 0;
for (const chunk of audioChunks) {
buffer.set(chunk, offset);
offset += chunk.length;
}
// Create DataView for 16-bit PCM
const pcmBuffer = new ArrayBuffer(length * 2);
const view = new DataView(pcmBuffer);
floatTo16BitPCM(view, 0, buffer);
// Send to backend
const blob = new Blob([view], { type: 'application/octet-stream' });
const formData = new FormData();
formData.append('audio', blob, 'recording.pcm');
const res = await fetch('/api/calendar_voice/recognize', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.ok) {
const currentText = els.voiceText.value;
// Append if there is existing text
els.voiceText.value = currentText ? (currentText + '\n' + data.text) : data.text;
} else {
alert('识别失败: ' + (data.error || '未知错误'));
}
} catch (e) {
console.error('Processing error:', e);
alert('上传或处理失败');
} finally {
showLoading(false);
}
}
// --- Text Processing Logic ---
function parseRelativeDate(text) {
const now = new Date();
// Reset time to 00:00:00 for calculation
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let targetDate = null;
// 1. Keywords: 今天, 明天, 后天, 前天
if (text.includes('今天')) targetDate = today;
else if (text.includes('明天')) targetDate = new Date(today.getTime() + 86400000);
else if (text.includes('后天') || text.includes('过两天')) targetDate = new Date(today.getTime() + 86400000 * 2);
else if (text.includes('前天')) targetDate = new Date(today.getTime() - 86400000 * 2);
// 2. Weekdays: 周X, 星期X, 下周X
// Map: 一->1, ... 六->6, 日/天->7
const weekMap = { '一':1, '二':2, '三':3, '四':4, '五':5, '六':6, '日':7, '天':7 };
const weekMatch = text.match(/(下)?(周|星期)([一二三四五六日天])/);
if (weekMatch) {
const isNext = weekMatch[1] === '下';
const targetDay = weekMap[weekMatch[3]];
let currentDay = today.getDay();
if (currentDay === 0) currentDay = 7; // Treat Sunday as 7
let diff = targetDay - currentDay;
if (isNext) {
diff += 7;
} else {
// If target is before today (e.g. Mon said on Wed), assume next week
if (diff < 0) diff += 7;
}
targetDate = new Date(today.getTime() + diff * 86400000);
}
if (targetDate) {
const y = targetDate.getFullYear();
const m = String(targetDate.getMonth() + 1).padStart(2, '0');
const d = String(targetDate.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return null;
}
async function parseTextToForm() {
const text = els.voiceText.value.trim();
if (!text) return;
// Show loading state
const originalBtnText = els.btnProcess.innerHTML;
els.btnProcess.disabled = true;
els.btnProcess.innerHTML = '🤖 正在智能分析...';
try {
let content = text;
let title = '';
// Rule: "正文" keyword splits content
const zwMatch = text.match(/正文(.*?)$/s);
if (zwMatch) {
content = zwMatch[1].trim();
}
// AI Summary
try {
const res = await fetch('/api/calendar_voice/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content })
});
const data = await res.json();
if (data.ok && data.title) {
title = data.title;
} else {
// Fallback if API fails
title = content.split('\n')[0].substring(0, 30);
}
} catch (e) {
console.error('Summary API error:', e);
title = content.split('\n')[0].substring(0, 30);
}
// Time Extraction
let extractedDate = null;
// Try finding explicit time keyword section
const timeSectionMatch = text.match(/时间(.*?)([,,。]|$)/);
const timeText = timeSectionMatch ? timeSectionMatch[1] : text;
// 1. Relative Date
extractedDate = parseRelativeDate(timeText);
// 2. If not found, try absolute date
if (!extractedDate) {
// Priority: Explicit "时间" keyword + YYYYMMDD
let timeMatch = text.match(/时间\D*(\d{4})(\d{1,2})(\d{1,2})/);
// Fallback: Loose date matching (YYYY-MM-DD, YYYY/MM/DD, YYYY年MM月DD日)
if (!timeMatch) {
timeMatch = text.match(/(\d{4})[.\-/年](\d{1,2})[.\-/月](\d{1,2})/);
}
if (timeMatch) {
const y = timeMatch[1];
const m = timeMatch[2].padStart(2, '0');
const d = timeMatch[3].padStart(2, '0');
extractedDate = `${y}-${m}-${d}`;
}
}
// Fill Form
els.title.value = title;
els.content.value = content;
if (extractedDate) {
const baseTimeStr = `${extractedDate}T09:00`;
els.start.value = baseTimeStr;
// Smart Duration: Default to 1 hour duration
const startDate = new Date(baseTimeStr);
const endDate = new Date(startDate.getTime() + 60 * 60 * 1000); // +1 hour
els.end.value = formatDateTimeLocal(endDate);
els.remind.value = baseTimeStr;
} else {
// 没有识别出任何日期:默认今天 09:00 开始,明天 09:00 结束,提醒为今天 09:00
const now = new Date();
const start = new Date(now);
start.setHours(9, 0, 0, 0);
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); // +1 day
els.start.value = formatDateTimeLocal(start);
els.end.value = formatDateTimeLocal(end);
els.remind.value = formatDateTimeLocal(start);
}
} catch (err) {
console.error('Parse error:', err);
alert('处理出错,请重试');
} finally {
els.btnProcess.disabled = false;
els.btnProcess.innerHTML = originalBtnText;
}
}
// --- Save Logic ---
async function saveEvent() {
const payload = {
title: els.title.value,
content: els.content.value,
start: els.start.value,
end: els.end.value,
remindAt: els.remind.value,
completed: false,
deleted: false
};
if (!payload.title) {
alert('请输入标题');
return;
}
if (!payload.start) {
alert('请输入开始时间');
return;
}
// Adjust format
if (payload.start.length === 16) payload.start += ':00';
if (payload.end && payload.end.length === 16) payload.end += ':00';
if (payload.remindAt && payload.remindAt.length === 16) payload.remindAt += ':00';
showLoading(true);
addLog('开始处理请求...');
try {
// Step 1: Validation
await new Promise(r => setTimeout(r, 500));
addLog('正在验证数据完整性...');
// Step 2: Save to DB
await new Promise(r => setTimeout(r, 500));
addLog('正在提交数据到本地数据库...');
const res = await fetch('/api/calendar/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
let data = null;
try {
data = await res.json();
} catch (e) {
addLog('服务器返回的不是有效 JSON,状态码: ' + res.status, 'error');
throw e;
}
if (data && data.ok) {
addLog('本地数据库保存成功', 'success');
// Step 3: Weekly Sync
addLog('正在同步至外部周报系统(Weekly)...');
// Simulate sync time (in reality this might happen in backend async)
await new Promise(r => setTimeout(r, 1500));
addLog('外部系统同步完成', 'success');
addLog('所有操作已完成 ✅', 'success');
document.getElementById('modal-spinner').classList.add('hidden');
document.getElementById('btn-modal-close').classList.remove('hidden');
clearForm();
} else {
const errMsg = String((data && data.error) || '未知错误');
addLog('保存失败: ' + errMsg, 'error');
try {
addLog('服务器返回详情: ' + JSON.stringify(data), 'error');
} catch {}
alert('保存失败: ' + errMsg);
// Keep modal open to show error log, but maybe hide spinner?
document.getElementById('modal-spinner').classList.add('hidden');
document.getElementById('btn-modal-close').classList.remove('hidden');
}
} catch (e) {
console.error(e);
const msg = e && e.message ? String(e.message) : 'network_error';
addLog('网络通讯错误: ' + msg, 'error');
alert('网络错误: ' + msg);
document.getElementById('modal-spinner').classList.add('hidden');
document.getElementById('btn-modal-close').classList.remove('hidden');
}
}
function clearForm() {
els.voiceText.value = '';
localStorage.removeItem('voice_draft'); // Clear draft
els.title.value = '';
els.content.value = '';
els.start.value = '';
els.end.value = '';
els.remind.value = '';
}
// --- Helpers ---
function roundToQuarter(d) {
const q = Math.round(d.getMinutes() / 15) * 15;
d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0);
return d;
}
function formatDateTimeLocal(d) {
const pad = (n) => String(n).padStart(2, '0');
const yyyy = d.getFullYear();
const MM = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const mm = pad(d.getMinutes());
return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
}
function showLoading(show, text = '加载中...') {
const overlay = document.getElementById('loading-overlay');
const spinner = document.getElementById('modal-spinner');
const btnClose = document.getElementById('btn-modal-close');
const logs = document.getElementById('modal-logs');
if (show) {
overlay.classList.remove('hidden');
spinner.classList.remove('hidden');
btnClose.classList.add('hidden');
logs.innerHTML = ''; // Clear logs
} else {
overlay.classList.add('hidden');
}
}
function addLog(text, type = 'normal') {
const logs = document.getElementById('modal-logs');
const item = document.createElement('div');
item.className = 'modal-log-item';
if (type === 'success') item.classList.add('success');
if (type === 'error') item.style.color = 'var(--danger-color)';
item.textContent = text;
logs.appendChild(item);
logs.scrollTop = logs.scrollHeight;
}
document.getElementById('btn-modal-close').addEventListener('click', () => {
showLoading(false);
});
// --- Event Listeners ---
els.btnRecord.addEventListener('click', () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
});
els.btnProcess.addEventListener('click', parseTextToForm);
els.btnSave.addEventListener('click', saveEvent);
els.btnClose.addEventListener('click', clearForm);
// Auto-save draft
els.voiceText.addEventListener('input', () => {
localStorage.setItem('voice_draft', els.voiceText.value);
});
// Load draft on startup
window.addEventListener('DOMContentLoaded', () => {
clearUrlQuery();
const draft = localStorage.getItem('voice_draft');
if (draft) {
els.voiceText.value = draft;
}
});
// "Now" buttons logic
document.querySelectorAll('.btn-now').forEach(btn => {
btn.addEventListener('click', (e) => {
const targetId = e.currentTarget.dataset.target;
const input = document.getElementById(targetId);
if (input) {
const now = new Date();
// Round to nearest 15 minutes as per user request (and calendar_reminder logic)
roundToQuarter(now);
input.value = formatDateTimeLocal(now);
}
});
});