121 lines
3.3 KiB
JavaScript
121 lines
3.3 KiB
JavaScript
const indicator = document.getElementById('duck-indicator')
|
|
const statusText = document.getElementById('status-text')
|
|
const toggleBtn = document.getElementById('toggle-btn')
|
|
const errorText = document.getElementById('error-text')
|
|
|
|
let detector = null
|
|
|
|
class SmartVoiceDetector {
|
|
constructor(options = {}) {
|
|
this.threshold = options.threshold ?? -50
|
|
this.silenceDelay = options.silenceDelay ?? 700
|
|
this.onSpeechStart = options.onSpeechStart || (() => {})
|
|
this.onSpeechEnd = options.onSpeechEnd || (() => {})
|
|
|
|
this.isSpeaking = false
|
|
this.timeout = null
|
|
this.audioContext = null
|
|
this.running = false
|
|
}
|
|
|
|
async start() {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
this.audioContext = new AudioContext()
|
|
const source = this.audioContext.createMediaStreamSource(stream)
|
|
const analyser = this.audioContext.createAnalyser()
|
|
|
|
analyser.fftSize = 1024
|
|
analyser.smoothingTimeConstant = 0.4
|
|
source.connect(analyser)
|
|
|
|
const bufferLength = analyser.frequencyBinCount
|
|
const dataArray = new Float32Array(bufferLength)
|
|
|
|
const sampleRate = this.audioContext.sampleRate
|
|
const minHz = 85
|
|
const maxHz = 3000
|
|
const minIndex = Math.floor(minHz / (sampleRate / analyser.fftSize))
|
|
const maxIndex = Math.ceil(maxHz / (sampleRate / analyser.fftSize))
|
|
|
|
this.running = true
|
|
const check = () => {
|
|
if (!this.running) return
|
|
|
|
analyser.getFloatFrequencyData(dataArray)
|
|
|
|
let maxVolume = -Infinity
|
|
for (let i = minIndex; i <= maxIndex; i++) {
|
|
if (dataArray[i] > maxVolume) maxVolume = dataArray[i]
|
|
}
|
|
|
|
if (maxVolume > this.threshold) {
|
|
if (!this.isSpeaking) {
|
|
this.isSpeaking = true
|
|
this.onSpeechStart()
|
|
}
|
|
clearTimeout(this.timeout)
|
|
this.timeout = null
|
|
} else if (this.isSpeaking && !this.timeout) {
|
|
this.timeout = setTimeout(() => {
|
|
this.isSpeaking = false
|
|
this.onSpeechEnd()
|
|
}, this.silenceDelay)
|
|
}
|
|
|
|
requestAnimationFrame(check)
|
|
}
|
|
|
|
check()
|
|
}
|
|
|
|
stop() {
|
|
this.running = false
|
|
if (this.audioContext) {
|
|
this.audioContext.close()
|
|
this.audioContext = null
|
|
}
|
|
}
|
|
}
|
|
|
|
toggleBtn.addEventListener('click', async () => {
|
|
if (detector) {
|
|
detector.stop()
|
|
detector = null
|
|
statusText.textContent = 'Stopped'
|
|
toggleBtn.textContent = 'Start'
|
|
indicator.classList.remove('speaking')
|
|
indicator.classList.add('silent')
|
|
return
|
|
}
|
|
|
|
toggleBtn.disabled = true
|
|
statusText.textContent = 'Starting...'
|
|
|
|
try {
|
|
detector = new SmartVoiceDetector({
|
|
threshold: -50,
|
|
onSpeechStart: () => {
|
|
indicator.classList.remove('silent')
|
|
indicator.classList.add('speaking')
|
|
statusText.textContent = 'Voice detected'
|
|
},
|
|
onSpeechEnd: () => {
|
|
indicator.classList.remove('speaking')
|
|
indicator.classList.add('silent')
|
|
statusText.textContent = 'Listening...'
|
|
},
|
|
})
|
|
|
|
await detector.start()
|
|
statusText.textContent = 'Listening...'
|
|
toggleBtn.textContent = 'Stop'
|
|
toggleBtn.disabled = false
|
|
} catch (err) {
|
|
console.error('Mic error:', err)
|
|
errorText.textContent = err.message || String(err)
|
|
errorText.classList.remove('hidden')
|
|
statusText.textContent = 'Error'
|
|
toggleBtn.textContent = 'Retry'
|
|
toggleBtn.disabled = false
|
|
}
|
|
}) |