diff --git a/demo/web/.prettierignore b/demo/web/.prettierignore new file mode 100644 index 0000000..1b8ac88 --- /dev/null +++ b/demo/web/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage diff --git a/demo/web/.prettierrc b/demo/web/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/demo/web/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/demo/web/README.md b/demo/web/README.md index 14bb3f4..8b4b503 100644 --- a/demo/web/README.md +++ b/demo/web/README.md @@ -32,5 +32,5 @@ Available on: Hit CTRL-C to stop the server ``` -Enter your AccessKey and wait until Eagle and the WebVoiceProcessor have initialized. Record audio or upload an audio +Enter your AccessKey and wait until Eagle and the WebVoiceProcessor have initialized. Record audio or upload an audio file to enroll speakers. Test speaker recognition in real-time with a microphone. diff --git a/demo/web/index.html b/demo/web/index.html index 4ad01df..ddd357e 100644 --- a/demo/web/index.html +++ b/demo/web/index.html @@ -1,424 +1,24 @@ - + - +

Eagle Web Demo

This demo uses Eagle for Web and the WebVoiceProcessor to:

    +
  1. Create an instance of Eagle with the model file provided.
  2. - Create an instance of Eagle with the model file provided. -
  3. -
  4. - Select an audio file or acquire microphone data stream and convert to voice - processing format (16kHz 16-bit linear PCM). The downsampled audio is - forwarded to the Eagle engine. The audio does not leave the + Select an audio file or acquire microphone data stream and convert to + voice processing format (16kHz 16-bit linear PCM). The downsampled audio + is forwarded to the Eagle engine. The audio does not leave the browser: all processing is occurring via the Eagle WebAssembly code.
  5. -
  6. - Enroll multiple speakers with audio files or a microphone. -
  7. -
  8. - Recognize which speaker is talking in real-time. -
  9. +
  10. Enroll multiple speakers with audio files or a microphone.
  11. +
  12. Recognize which speaker is talking in real-time.
After entering the AccessKey, click the "Start Eagle" button.
@@ -433,46 +33,50 @@

Eagle Web Demo

value="Start Eagle" onclick="startEagleProfiler(document.getElementById('accessKey').value)" /> -
+
diff --git a/demo/web/package.json b/demo/web/package.json index fdb16b3..7dddb98 100644 --- a/demo/web/package.json +++ b/demo/web/package.json @@ -22,6 +22,7 @@ "@picovoice/web-voice-processor": "~4.0.8" }, "devDependencies": { - "http-server": "^14.0.0" + "http-server": "^14.0.0", + "prettier": "3.5.1" } } diff --git a/demo/web/scripts/eagle.js b/demo/web/scripts/eagle.js new file mode 100644 index 0000000..9401696 --- /dev/null +++ b/demo/web/scripts/eagle.js @@ -0,0 +1,418 @@ +let profiler = null; +let eagle = null; +let speakerProfiles = []; + +let timer = null; +let currentTimer = 0.0; +let audioData = []; +let audioContext = null; + +const ENABLE_AUDIO_DUMP = false; +let dumpAudio = []; + +window.onload = function () { + audioContext = new (window.AudioContext || window.webKitAudioContext)({ + sampleRate: 16000, + }); + + document + .getElementById("audioFile") + .addEventListener("change", async (event) => { + newEnrollmentUI(); + try { + await profiler.reset(); + } catch (e) { + writeMessage(`Failed to reset Eagle Profiler. Error: ${e}`); + return; + } + + writeMessage("Processing audio files..."); + const fileList = event.target.files; + let percentage = 0; + for (const f of fileList) { + let audioFrame; + try { + audioFrame = await getAudioFileData(f, audioContext); + } catch (e) { + writeMessage(`Failed to read audio file '${f.name}'. Error: ${e}`); + return; + } + + try { + const result = await profiler.enroll(audioFrame); + updateSpeakerProgress( + speakerProfiles.length + 1, + result.feedback, + result.percentage, + ); + percentage = result.percentage; + } catch (e) { + writeMessage(`Failed to enroll using '${f.name}'. Error: ${e}`); + return; + } + } + + if (percentage < 100) { + writeMessage( + `Speaker is only ${percentage}% done enrollment. ` + + `Please choose a larger data set and try again.`, + ); + enrollFailUI(); + return; + } + + try { + const profile = await profiler.export(); + speakerProfiles.push(profile); + enrollSuccessUI(); + writeMessage( + `Enrollment for Speaker ${speakerProfiles.length} complete! ` + + `You can begin testing or enroll another speaker.`, + ); + } catch (e) { + writeMessage(`Failed to enroll speaker. Error: ${e}`); + } + }); +}; + +async function getAudioFileData(audioFile, audioContext) { + const dataBuffer = await audioFile.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(dataBuffer); + + const f32PCM = audioBuffer.getChannelData(0); + const i16PCM = new Int16Array(f32PCM.length); + + const INT16_MAX = 32767; + const INT16_MIN = -32768; + i16PCM.set( + f32PCM.map((f) => { + let i = Math.trunc(f * INT16_MAX); + if (f > INT16_MAX) i = INT16_MAX; + if (f < INT16_MIN) i = INT16_MIN; + return i; + }), + ); + return i16PCM; +} + +const micEnrollEngine = { + onmessage: async (event) => { + switch (event.data.command) { + case "process": + audioData.push(event.data.inputFrame); + + if (ENABLE_AUDIO_DUMP) { + dumpAudio = dumpAudio.concat(event.data.inputFrame); + } + + if (audioData.length * 512 >= profiler.minEnrollSamples) { + let result; + + try { + const frames = new Int16Array(audioData.length * 512); + for (let i = 0; i < audioData.length; i++) { + frames.set(audioData[i], i * 512); + } + audioData = []; + result = await profiler.enroll(frames); + } catch (e) { + writeMessage(`Failed to enroll. Error: ${e}`); + return; + } + + updateSpeakerProgress( + speakerProfiles.length + 1, + result.feedback, + result.percentage, + ); + if (result.percentage === 100) { + await window.WebVoiceProcessor.WebVoiceProcessor.unsubscribe( + micEnrollEngine, + ); + clearInterval(timer); + micEnrollStopUI(); + + try { + const profile = await profiler.export(); + speakerProfiles.push(profile); + enrollSuccessUI(); + writeMessage( + `Enrollment for Speaker ${speakerProfiles.length} complete! ` + + `You can begin testing or enroll another speaker.`, + ); + } catch (e) { + writeMessage(`Failed to enroll speaker. Error: ${e}`); + } + + if (ENABLE_AUDIO_DUMP) { + downloadDumpAudio("enroll.pcm"); + } + } + } + break; + } + }, +}; + +async function micEnrollStart() { + currentTimer = 0.0; + audioData = []; + micEnrollStartUI(); + newEnrollmentUI(); + try { + await profiler.reset(); + } catch (e) { + writeMessage(`Failed to reset Eagle Profiler. Error: ${e}`); + return; + } + + writeMessage( + "Keep speaking continuously until enrollment progress reaches 100%.", + ); + try { + await window.WebVoiceProcessor.WebVoiceProcessor.subscribe(micEnrollEngine); + timer = setInterval(() => { + currentTimer += 0.1; + document.getElementById("displayTimer").innerText = + `${currentTimer.toFixed(1)}`; + }, 100); + } catch (e) { + writeMessage(e); + } +} + +async function micEnrollStop() { + await window.WebVoiceProcessor.WebVoiceProcessor.unsubscribe(micEnrollEngine); + clearInterval(timer); + writeMessage(`Enrollment stopped`); + updateSpeakerTable(); + enrollFailUI(); + micEnrollStopUI(); +} + +function writeMessage(message) { + console.log(message); + document.getElementById("status").innerHTML = message; +} + +function updateSpeakerProgress(speakerId, feedback, progress) { + const feedbackMsg = getFeedbackMessage(feedback); + console.log(progress, feedbackMsg); + document.getElementById(`speaker${speakerId}Feedback`).innerHTML = + ` ${feedbackMsg}`; + document.getElementById(`speaker${speakerId}Progress`).value = progress; +} + +function updateSpeakerScore(speakerId, score) { + document.getElementById(`speaker${speakerId}Progress`).value = score * 100; +} + +function getFeedbackMessage(feedback) { + switch (feedback) { + case EagleWeb.EagleProfilerEnrollFeedback.AUDIO_TOO_SHORT: + return "Insufficient audio length"; + case EagleWeb.EagleProfilerEnrollFeedback.UNKNOWN_SPEAKER: + return "Different speaker detected in audio"; + case EagleWeb.EagleProfilerEnrollFeedback.NO_VOICE_FOUND: + return "Unable to detect voice in audio"; + case EagleWeb.EagleProfilerEnrollFeedback.QUALITY_ISSUE: + return "Audio quality too low to use for enrollment"; + default: + return "Enrolling speaker..."; + } +} + +function updateSpeakerTable() { + const speakerTable = document.getElementById("speakersTable"); + while (speakerTable.lastElementChild) { + speakerTable.removeChild(speakerTable.lastElementChild); + } + for (let i = 0; i < speakerProfiles.length; i++) { + speakerTable.append(createSpeakerRow(i + 1, 100)); + speakerTable.append(document.createElement("br")); + } +} + +function createSpeakerRow(i, initialProgress) { + const div = document.createElement("div"); + div.textContent = `Speaker ${i} `; + const speakerProgress = document.createElement("progress"); + speakerProgress.max = 100; + speakerProgress.value = initialProgress; + speakerProgress.id = `speaker${i}Progress`; + const speakerFeedback = document.createElement("span"); + speakerFeedback.id = `speaker${i}Feedback`; + div.appendChild(speakerProgress); + div.appendChild(speakerFeedback); + return div; +} + +function newEnrollmentUI() { + updateSpeakerTable(); + document.getElementById("testContainer").style.display = "block"; + document.getElementById("feedbackText").innerHTML = ""; + document + .getElementById("speakersTable") + .append(createSpeakerRow(speakerProfiles.length + 1, 0)); + document.getElementById("speakersTable").append(document.createElement("br")); + document.getElementById("testStartBtn").disabled = true; + document.getElementById("resetBtn").disabled = true; +} + +function enrollFailUI() { + document.getElementById("testStartBtn").disabled = + speakerProfiles.length === 0; + document.getElementById("resetBtn").disabled = speakerProfiles.length === 0; +} + +function enrollSuccessUI() { + updateSpeakerTable(); + document.getElementById("testStartBtn").disabled = false; + document.getElementById("resetBtn").disabled = false; +} + +function micEnrollStartUI() { + document.getElementById("displayTimer").style.display = "inline"; + document.getElementById("micEnrollStartBtn").style.display = "none"; + document.getElementById("audioFile").disabled = true; + document.getElementById("micEnrollStopBtn").style.display = "inline"; +} + +function micEnrollStopUI() { + document.getElementById("displayTimer").style.display = "none"; + document.getElementById("micEnrollStartBtn").style.display = "inline"; + document.getElementById("audioFile").disabled = false; + document.getElementById("micEnrollStopBtn").style.display = "none"; + document.getElementById("displayTimer").innerText = `0.0`; +} + +function micTestStartUI() { + document.getElementById("micEnrollStartBtn").disabled = true; + document.getElementById("audioFile").disabled = true; + document.getElementById("resetBtn").disabled = true; + document.getElementById("testStartBtn").style.display = "none"; + document.getElementById("testStopBtn").style.display = "inline"; + document.getElementById("testStopBtn").disabled = true; +} + +function micTestStopUI() { + document.getElementById("micEnrollStartBtn").disabled = false; + document.getElementById("audioFile").disabled = false; + document.getElementById("resetBtn").disabled = false; + document.getElementById("testStartBtn").style.display = "inline"; + document.getElementById("testStopBtn").style.display = "none"; +} + +async function startEagleProfiler(accessKey) { + writeMessage("Eagle is loading. Please wait..."); + try { + profiler = await EagleWeb.EagleProfilerWorker.create(accessKey, { + base64: modelParams, + forceWrite: true, + }); + writeMessage(""); + + document.getElementById("enrollContainer").style.display = "block"; + } catch (e) { + writeMessage(`Failed to initialize Eagle. Error: ${e}`); + } +} + +const micTestEngine = { + onmessage: async (event) => { + switch (event.data.command) { + case "process": + if (ENABLE_AUDIO_DUMP) { + dumpAudio = dumpAudio.concat(event.data.inputFrame); + } + let scores; + try { + scores = await eagle.process(event.data.inputFrame); + } catch (e) { + writeMessage(`Failed to enroll. Error: ${e}`); + return; + } + + for (let i = 0; i < scores.length; i++) { + updateSpeakerScore(i + 1, scores[i]); + } + break; + } + }, +}; + +async function startEagle(accessKey) { + writeMessage("Eagle is loading. Please wait..."); + micTestStartUI(); + try { + eagle = await EagleWeb.EagleWorker.create( + accessKey, + { + base64: modelParams, + forceWrite: true, + }, + speakerProfiles, + ); + } catch (e) { + writeMessage(`Failed to initialize Eagle. Error: ${e}`); + return; + } + try { + await profiler.reset(); + } catch (e) { + writeMessage(`Failed to reset Eagle Profiler. Error: ${e}`); + return; + } + + document.getElementById("testStopBtn").disabled = false; + for (let i = 0; i < speakerProfiles.length; i++) { + updateSpeakerScore(i + 1, 0); + } + writeMessage( + "Take turns speaking sentences and see if Eagle can recognize which speaker is talking", + ); + try { + await window.WebVoiceProcessor.WebVoiceProcessor.subscribe(micTestEngine); + } catch (e) { + writeMessage(e); + } +} + +async function stopEagle() { + try { + await window.WebVoiceProcessor.WebVoiceProcessor.unsubscribe(micTestEngine); + } catch (e) { + writeMessage(e); + } + + if (eagle) { + await eagle.terminate(); + eagle = null; + } + for (let i = 0; i < speakerProfiles.length; i++) { + updateSpeakerScore(i + 1, 100); + } + micTestStopUI(); + writeMessage(""); + + if (ENABLE_AUDIO_DUMP) { + downloadDumpAudio("test.pcm"); + } +} + +function resetSpeakers() { + document.getElementById("testContainer").style.display = "none"; + speakerProfiles = []; + updateSpeakerTable(); + writeMessage(""); +} + +function downloadDumpAudio(fileName) { + let blob = new Blob(dumpAudio); + dumpAudio = []; + let a = document.createElement("a"); + a.download = fileName; + a.href = window.URL.createObjectURL(blob); + a.click(); + document.removeChild(a); +} diff --git a/demo/web/yarn.lock b/demo/web/yarn.lock index 27ab4dd..3b1576d 100644 --- a/demo/web/yarn.lock +++ b/demo/web/yarn.lock @@ -235,6 +235,11 @@ portfinder@^1.0.28: debug "^3.2.7" mkdirp "^0.5.6" +prettier@3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.1.tgz#22fac9d0b18c0b92055ac8fb619ac1c7bef02fb7" + integrity sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw== + qs@^6.4.0: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"