feat: MorseAudioEngine with Web Audio API and event emitter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { MORSE, wpmToTiming } from './morse'
|
||||
|
||||
type SignalType = 'dot' | 'dash'
|
||||
type AudioEventName = 'signal-start' | 'signal-end' | 'play-end'
|
||||
type AudioEventPayload = { type: SignalType } | undefined
|
||||
|
||||
export class MorseAudioEngine {
|
||||
private ctx: AudioContext | null = null
|
||||
private listeners = new Map<AudioEventName, Array<(data?: AudioEventPayload) => void>>()
|
||||
// Monotonically increasing. stop() increments it. play() records its value
|
||||
// at the moment it clears the stopped state. A play session is valid only
|
||||
// while activePlayId === stopGeneration (i.e. no new stop() has been called).
|
||||
private stopGeneration = 0
|
||||
private activePlayId = 0
|
||||
|
||||
private getCtx(): AudioContext {
|
||||
if (!this.ctx || this.ctx.state === 'closed') {
|
||||
this.ctx = new AudioContext()
|
||||
}
|
||||
return this.ctx
|
||||
}
|
||||
|
||||
on(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
|
||||
this.listeners.set(event, [...(this.listeners.get(event) ?? []), cb])
|
||||
}
|
||||
|
||||
off(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
|
||||
this.listeners.set(event, (this.listeners.get(event) ?? []).filter(l => l !== cb))
|
||||
}
|
||||
|
||||
private emit(event: AudioEventName, data?: AudioEventPayload): void {
|
||||
;(this.listeners.get(event) ?? []).forEach(cb => cb(data))
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopGeneration++
|
||||
this.ctx?.suspend()
|
||||
}
|
||||
|
||||
async play(text: string, wpm = 10): Promise<void> {
|
||||
// To allow playback, we align activePlayId with stopGeneration.
|
||||
// But only do this if stop() was NOT called before this play() invocation.
|
||||
// We detect "stop-before-play" by checking if stopGeneration > activePlayId
|
||||
// (meaning stop was called more recently than the last play start).
|
||||
// If so, we do NOT reset, so isStopped() remains true throughout.
|
||||
const wasStoppedBeforePlay = this.stopGeneration > this.activePlayId
|
||||
if (!wasStoppedBeforePlay) {
|
||||
// Normal: no prior unmatched stop — activate this play session
|
||||
this.activePlayId = this.stopGeneration
|
||||
}
|
||||
|
||||
const myPlayId = this.activePlayId
|
||||
const isStopped = () => this.stopGeneration !== myPlayId
|
||||
|
||||
const ctx = this.getCtx()
|
||||
if (ctx.state === 'suspended') await ctx.resume()
|
||||
|
||||
const t = wpmToTiming(wpm)
|
||||
let scheduleTime = ctx.currentTime + 0.05
|
||||
const events: Array<{ delayMs: number; event: AudioEventName; data?: AudioEventPayload }> = []
|
||||
|
||||
for (const char of text.toUpperCase()) {
|
||||
if (isStopped()) break
|
||||
if (char === ' ') {
|
||||
scheduleTime += t.wordGap / 1000
|
||||
continue
|
||||
}
|
||||
const pattern = MORSE[char]
|
||||
if (!pattern) continue
|
||||
|
||||
let firstElement = true
|
||||
for (const el of pattern) {
|
||||
if (isStopped()) break
|
||||
if (!firstElement) scheduleTime += t.elementGap / 1000
|
||||
firstElement = false
|
||||
|
||||
const isDot = el === '.'
|
||||
const type: SignalType = isDot ? 'dot' : 'dash'
|
||||
const dur = (isDot ? t.dot : t.dash) / 1000
|
||||
|
||||
// Schedule audio
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.frequency.value = 700
|
||||
osc.type = 'sine'
|
||||
gain.gain.setValueAtTime(0, scheduleTime)
|
||||
gain.gain.linearRampToValueAtTime(0.5, scheduleTime + 0.005)
|
||||
gain.gain.linearRampToValueAtTime(0, scheduleTime + dur - 0.005)
|
||||
osc.start(scheduleTime)
|
||||
osc.stop(scheduleTime + dur)
|
||||
|
||||
const nowMs = (scheduleTime - ctx.currentTime) * 1000
|
||||
events.push({ delayMs: nowMs, event: 'signal-start', data: { type } })
|
||||
events.push({ delayMs: nowMs + dur * 1000, event: 'signal-end', data: { type } })
|
||||
|
||||
scheduleTime += dur
|
||||
}
|
||||
scheduleTime += (t.charGap - t.elementGap) / 1000
|
||||
}
|
||||
|
||||
const totalMs = Math.max(0, (scheduleTime - ctx.currentTime) * 1000)
|
||||
|
||||
// Fire events via setTimeout aligned to AudioContext schedule
|
||||
for (const { delayMs, event, data } of events) {
|
||||
setTimeout(() => {
|
||||
if (!isStopped()) this.emit(event, data)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => setTimeout(resolve, totalMs))
|
||||
if (!isStopped()) this.emit('play-end')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user