diff --git a/音频分类/script.js b/音频分类/script.js index 5ef92fe..2d9b7e8 100644 --- a/音频分类/script.js +++ b/音频分类/script.js @@ -1,18 +1,17 @@ // 全局变量和模型实例 -let recognizer; // 基础的 SpeechCommands recognizer -let transferRecognizer; // 用于迁移学习的 recognizer -const labels = []; // 用户定义的类别标签数组 (包括背景噪音) -// 将背景噪音定义为第一个类别,其内部名称为 _background_noise_ +let recognizer; +let transferRecognizer; +// labels 现在将根据导入的数据动态重建,但仍需初始化 +let labels = []; const BACKGROUND_NOISE_LABEL = '_background_noise_'; -const BACKGROUND_NOISE_INDEX = 0; // 仅用于本地 labels 数组索引,不直接用于collectExample -let isPredicting = false; // 预测状态标志 -let isRecording = false; // 录音状态标志,防止重复点击 -const recordDuration = 1000; // 每个样本的录音时长 (毫秒) -let isModelTrainedFlag = false; // 手动维护模型训练状态 -let predictionStopFunction = null; // 存储 transferRecognizer.listen() 返回的停止函数 +let isPredicting = false; +let isRecording = false; +const recordDuration = 1000; +let isModelTrainedFlag = false; +let predictionStopFunction = null; -// UI 元素引用 (保持不变) +// UI 元素引用 (已更新) const statusDiv = document.getElementById('status'); const backgroundNoiseSampleCountSpan = document.getElementById('backgroundNoiseSampleCount'); const recordBackgroundNoiseBtn = document.getElementById('recordBackgroundNoiseBtn'); @@ -24,47 +23,50 @@ const startPredictingBtn = document.getElementById('startPredictingBtn'); const stopPredictingBtn = document.getElementById('stopPredictingBtn'); const predictionResultDiv = document.getElementById('predictionResult'); +// ===== 新增UI元素引用 ===== +const exportModelBtn = document.getElementById('exportModelBtn'); +const importModelBtn = document.getElementById('importModelBtn'); +const importFileInput = document.getElementById('importFileInput'); + + // ======================= 初始化函数 ======================= async function init() { statusDiv.innerText = '正在加载 TensorFlow.js 和 Speech Commands 模型...'; try { - recognizer = speechCommands.create( - 'BROWSER_FFT' // 使用浏览器内置的 FFT 处理,性能更好 - ); - + recognizer = speechCommands.create('BROWSER_FFT'); await recognizer.ensureModelLoaded(); - transferRecognizer = recognizer.createTransfer('my-custom-model'); - // 只有在 transferRecognizer 创建成功后,才将背景噪音标签加入我们的 local labels 数组 - labels.push(BACKGROUND_NOISE_LABEL); // 仅用于本地 UI 映射和预测结果查找 + // 初始化时清空并设置背景噪音标签 + labels = [BACKGROUND_NOISE_LABEL]; - statusDiv.innerText = '模型加载成功!你可以开始录制背景噪音和自定义声音样本了。'; + statusDiv.innerText = '模型加载成功!你可以开始录制、或导入已有的样本数据。'; recordBackgroundNoiseBtn.disabled = false; addCategoryBtn.disabled = false; + importModelBtn.disabled = false; // 允许导入 + exportModelBtn.disabled = true; // 尚无数据,默认禁用导出按钮 trainModelBtn.disabled = true; startPredictingBtn.disabled = true; stopPredictingBtn.disabled = true; - isModelTrainedFlag = false; // 重置训练状态 + isModelTrainedFlag = false; + + // --- 修正之处:移除此处对 checkTrainingReadiness() 的调用 --- + // checkTrainingReadiness(); // <--- 移除这一行! } catch (error) { statusDiv.innerText = `模型加载失败或麦克风无法访问: ${error.message}. 请检查麦克风权限和网络连接。`; console.error('初始化失败:', error); - // 任何失败都禁用所有控制,直到初始化成功 - recordBackgroundNoiseBtn.disabled = true; - addCategoryBtn.disabled = true; - trainModelBtn.disabled = true; - startPredictingBtn.disabled = true; - stopPredictingBtn.disabled = true; - isModelTrainedFlag = false; // 重置训练状态 + // 禁用所有按钮 + const buttons = document.querySelectorAll('button'); + buttons.forEach(btn => btn.disabled = true); + isModelTrainedFlag = false; } } // ======================= 批量录制样本的通用函数 ======================= -// recordMultipleExamples传入 label, 样本数量显示元素, 按钮元素, 一次录制的样本数量 -async function recordMultipleExamples(label, sampleCountSpanElement, buttonElement, countToRecord = 5) { // 默认一次录制5个样本 +async function recordMultipleExamples(label, sampleCountSpanElement, buttonElement, countToRecord = 5) { if (isRecording) { statusDiv.innerText = '请等待当前录音完成...'; return; @@ -83,9 +85,8 @@ async function recordMultipleExamples(label, sampleCountSpanElement, buttonEleme ); const exampleCounts = transferRecognizer.countExamples(); sampleCountSpanElement.innerText = exampleCounts[label] || 0; - // 在每次录音之间增加短暂延迟,以便更好地分离样本 if (i < countToRecord - 1) { - await new Promise(resolve => setTimeout(resolve, Math.max(200, recordDuration / 5))); // 至少 200ms 或录音时长的 1/5 + await new Promise(resolve => setTimeout(resolve, Math.max(200, recordDuration / 5))); } } catch (error) { statusDiv.innerText = `录制 "${label}" 样本失败: ${error.message}`; @@ -102,16 +103,15 @@ async function recordMultipleExamples(label, sampleCountSpanElement, buttonEleme statusDiv.innerText = `已为 "${label}" 收集了 ${transferRecognizer.countExamples()[label] || 0} 个样本。`; } -// ======================= 背景噪音样本收集 ======================= -// 按钮点击事件 + +// ============== 背景噪音样本收集 (无修改) ================== recordBackgroundNoiseBtn.onclick = async () => { await recordMultipleExamples(BACKGROUND_NOISE_LABEL, backgroundNoiseSampleCountSpan, recordBackgroundNoiseBtn, 5); }; +// ======================= 自定义类别管理 ======================= -// ======================= 自定义类别管理和样本收集 ======================= - -// 添加新类别到 UI 和逻辑 (用于自定义声音) +// 添加新类别(用户手动添加) function addCustomCategory(categoryName) { if (!categoryName) { alert('类别名称不能为空!'); @@ -122,20 +122,33 @@ function addCustomCategory(categoryName) { alert(`类别 "${categoryName}" 已经存在!`); return; } - // 将标签添加到本地数组以供 UI 逻辑和后续预测结果查找使用 labels.push(categoryName); - // 创建类别块 UI - const categoryBlock = document.createElement('div'); - categoryBlock.className = 'category-block'; - + // 创建UI时样本数量为0 + createCategoryUI(categoryName, 0); + newCategoryNameInput.value = ''; // 清空输入框 + checkTrainingReadiness(); // 添加新类别后检查训练就绪状态 +} + +// 添加自定义类别按钮点击事件 +addCategoryBtn.onclick = () => { + addCustomCategory(newCategoryNameInput.value.trim()); +}; + +// 创建类别UI的辅助函数(用于手动添加和导入后重建) +function createCategoryUI(categoryName, sampleCount) { // categoryId 此时仅用于生成唯一的 ID,不直接传给 collectExample const categoryId = labels.indexOf(categoryName); + const categoryBlock = document.createElement('div'); + categoryBlock.className = 'category-block'; + // 添加一个ID以便后续删除或识别 + categoryBlock.id = `category-block-${encodeURIComponent(categoryName)}`; + categoryBlock.innerHTML = `
样本数量: 0
+样本数量: ${sampleCount}
`; categoryContainer.appendChild(categoryBlock); @@ -147,25 +160,23 @@ function addCustomCategory(categoryName) { recordBtn.onclick = async () => { await recordMultipleExamples(categoryName, sampleCountSpan, recordBtn, 5); }; - - newCategoryNameInput.value = ''; // 清空输入框 - checkTrainingReadiness(); // 添加新类别后检查训练就绪状态 } -// 添加自定义类别按钮点击事件 -addCategoryBtn.onclick = () => { - addCustomCategory(newCategoryNameInput.value.trim()); -}; -// ======================= 检查训练就绪状态 ======================= +// ======================= 状态检查 ======================= function checkTrainingReadiness() { const exampleCounts = transferRecognizer.countExamples(); + // 检查是否有任何样本,以决定是否启用“导出”按钮 + const totalSamples = Object.values(exampleCounts).reduce((acc, count) => acc + count, 0); + exportModelBtn.disabled = totalSamples === 0; + let backgroundNoiseReady = (exampleCounts[BACKGROUND_NOISE_LABEL] || 0) > 0; let customCategoriesReady = 0; // 遍历本地 labels 数组,检查每个自定义类别是否有样本 - for (let i = 1; i < labels.length; i++) { // 从索引 1 开始,因为 0 是背景噪音 + // 从索引 1 开始,因为 0 是背景噪音 + for (let i = 1; i < labels.length; i++) { const customLabel = labels[i]; if ((exampleCounts[customLabel] || 0) > 0) { customCategoriesReady++; @@ -179,9 +190,10 @@ function checkTrainingReadiness() { trainModelBtn.disabled = true; } } -// ======================= 模型训练 ======================= + +// ======================= 模型训练 (无修改) ======================= trainModelBtn.onclick = async () => { - const exampleCounts = transferRecognizer.countExamples(); // 确保这里获取到了最新的样本数量 + const exampleCounts = transferRecognizer.countExamples(); console.log('--- DEBUG: 训练开始前,各类别样本数量:', exampleCounts); let totalExamples = 0; @@ -252,8 +264,8 @@ trainModelBtn.onclick = async () => { } }; -// ======================= 实时预测 ======================= -startPredictingBtn.onclick = async () => { // 确保此函数是 async +// ======================= 实时预测 (无修改) ======================= +startPredictingBtn.onclick = async () => { console.log('--- DEBUG: 点击开始识别时, isModelTrainedFlag 为:', isModelTrainedFlag); if (isPredicting) { statusDiv.innerText = '识别已经在进行中...'; @@ -278,8 +290,7 @@ startPredictingBtn.onclick = async () => { // 确保此函数是 async statusDiv.innerText = '正在开始识别... 请发出你训练过的声音。'; predictionResultDiv.innerText = '等待识别结果...'; - // <<< 核心修正:捕获 transferRecognizer.listen() 返回的停止函数时使用 await - predictionStopFunction = await transferRecognizer.listen(result => { // !!!这里加上了 await !!! + predictionStopFunction = await transferRecognizer.listen(result => { if (!isPredicting) return; // `transferRecognizer.wordLabels()` 会返回 transferRecognizer 内部按顺序排列的所有标签名称。 @@ -305,7 +316,6 @@ startPredictingBtn.onclick = async () => { // 确保此函数是 async overlapFactor: 0.50, }); - // 可以在这里添加一个调试日志,确认 predictionStopFunction 确实是一个函数 console.log('--- DEBUG: predictionStopFunction 赋值后:', predictionStopFunction); console.log('--- DEBUG: typeof predictionStopFunction 赋值后:', typeof predictionStopFunction); @@ -313,10 +323,9 @@ startPredictingBtn.onclick = async () => { // 确保此函数是 async stopPredictingBtn.onclick = () => { if (isPredicting) { - // 增加一个额外的类型检查,确保它确实是一个函数 - if (typeof predictionStopFunction === 'function') { // 确保是函数才调用 - predictionStopFunction(); // 调用停止识别的函数 - predictionStopFunction = null; // 清除引用,避免内存泄漏,也防止二次调用 + if (typeof predictionStopFunction === 'function') { + predictionStopFunction(); + predictionStopFunction = null; } else { console.warn('--- WARN: predictionStopFunction 不是一个函数,无法停止监听。'); } @@ -340,5 +349,144 @@ stopPredictingBtn.onclick = () => { } }; + +// ======================= 新增:模型导出功能 ======================= +exportModelBtn.onclick = async () => { + try { + // 序列化所有收集到的样本数据 + const serializedExamples = transferRecognizer.serializeExamples(); + + // 创建一个 Blob 对象 + const blob = new Blob([serializedExamples], { type: 'application/octet-stream' }); + + // 创建一个下载链接 + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + // 定制文件名,包含日期和时间 + const now = new Date(); + const filename = `speech_commands_data_${now.getFullYear()}${(now.getMonth()+1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}_${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}${now.getSeconds().toString().padStart(2, '0')}.bin`; + a.download = filename; + + // 模拟点击下载 + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); // 释放内存 + + statusDiv.innerText = `数据已成功导出为 "${filename}"。`; + } catch (error) { + statusDiv.innerText = `导出数据失败: ${error.message}`; + console.error('导出数据失败:', error); + alert('导出数据失败。请确保您已录制至少一个样本!'); + } +}; + +// ======================= 新增:模型导入功能 ======================= +importModelBtn.onclick = () => { + // 触发隐藏的文件输入框点击事件 + importFileInput.click(); +}; + +importFileInput.onchange = async (event) => { + const file = event.target.files[0]; + if (!file) { + statusDiv.innerText = '未选择文件。'; + return; + } + + if (!file.name.endsWith('.bin')) { + alert('请选择后缀名为 .bin 的文件!'); + statusDiv.innerText = '文件格式不正确,请选择 .bin 文件。'; + // 清空文件输入,以便用户可以选择其他文件 + importFileInput.value = ''; + return; + } + + statusDiv.innerText = `正在导入文件 "${file.name}"...`; + + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const dataBuffer = e.target.result; // 获取文件的 ArrayBuffer 内容 + + // 清除当前的 transferRecognizer 中的所有样本 + // SpeechCommands库中没有直接的clearExamples方法, + // 最简单的做法是重新创建一个 transferRecognizer 实例。 + // 但更好的做法是先尝试loadExamples,如果需要重置,再做。 + // 假设导入是“覆盖”现有样本的。 + // TODO: 这里可以考虑增加用户确认是否清除现有样本的提示 + + // 导入样本。这会自动更新 internal model + await transferRecognizer.loadExamples(dataBuffer); + + // 成功导入后,刷新UI + await syncUIWithLoadedData(); + + statusDiv.innerText = `文件 "${file.name}" 导入成功!`; + alert(`已成功导入 ${transferRecognizer.countExamples()._numExamples_ || 0} 个样本!`); + + } catch (error) { + statusDiv.innerText = `导入数据失败: ${error.message}. 确保存储的是有效的模型样本数据。`; + console.error('导入数据失败:', error); + alert(`导入数据失败。请检查文件是否损坏或格式不正确。\n错误: ${error.message}`); + } finally { + // 清空文件输入,以便下次选择相同文件也能触发 onchange + importFileInput.value = ''; + } + }; + reader.onerror = (error) => { + statusDiv.innerText = `读取文件失败: ${error.message}`; + console.error('文件读取失败:', error); + alert('文件读取失败。'); + importFileInput.value = ''; + }; + reader.readAsArrayBuffer(file); // 以 ArrayBuffer 格式读取文件 +}; + +// ======================= 新增辅助函数:导入后同步UI ======================= +async function syncUIWithLoadedData() { + // 清空现有除了背景噪音以外的类别块 + // 遍历所有子元素,从后向前删除,避免索引问题 + while (categoryContainer.firstChild) { + categoryContainer.removeChild(categoryContainer.firstChild); + } + + // 重置全局 labels 数组,只保留背景噪音 + labels = [BACKGROUND_NOISE_LABEL]; + + // 获取导入后的样本计数 + const exampleCounts = transferRecognizer.countExamples(); + console.log('--- DEBUG: 导入后样本数量:', exampleCounts); + + // 更新背景噪音样本数量 + backgroundNoiseSampleCountSpan.innerText = exampleCounts[BACKGROUND_NOISE_LABEL] || 0; + + // 重新构建自定义类别 UI + for (const label of Object.keys(exampleCounts)) { + if (label === BACKGROUND_NOISE_LABEL || label === '_version_' || label === '_numExamples_') { + continue; // 跳过背景噪音和内部元数据标签 + } + + // 将导入的自定义标签添加到我们的 labels 数组 + if (!labels.includes(label)) { + labels.push(label); + } + // 根据导入的数据创建 UI + createCategoryUI(label, exampleCounts[label]); + } + + // 重置模型的训练状态 + isModelTrainedFlag = false; + trainModelBtn.disabled = true; // 训练按钮默认禁用,等待 checkTrainingReadiness 启用 + startPredictingBtn.disabled = true; // 预测按钮禁用 + stopPredictingBtn.disabled = true; // 停止按钮禁用 + + // 检查训练就绪状态(现在有样本了,这个调用是安全的) + checkTrainingReadiness(); +} + + // ======================= 页面加载时执行 ======================= window.onload = init; + diff --git a/音频分类/voice.html b/音频分类/voice.html index c9396ae..9919500 100644 --- a/音频分类/voice.html +++ b/音频分类/voice.html @@ -4,7 +4,7 @@