feat: MorseAudioEngine with Web Audio API and event emitter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:23:30 +00:00
parent 1bfd5a22fe
commit f62490820a
2 changed files with 170 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { MorseAudioEngine } from './audio'
describe('MorseAudioEngine', () => {
let engine: MorseAudioEngine
beforeEach(() => {
engine = new MorseAudioEngine()
vi.useFakeTimers()
})
it('emits signal-start and signal-end events when playing E (.)', async () => {
const starts: string[] = []
const ends: string[] = []
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
engine.on('signal-end', ({ type }: { type: string }) => ends.push(type))
const playPromise = engine.play('E', 10)
await vi.runAllTimersAsync()
await playPromise
expect(starts).toEqual(['dot'])
expect(ends).toEqual(['dot'])
})
it('emits play-end after playback completes', async () => {
let ended = false
engine.on('play-end', () => { ended = true })
const playPromise = engine.play('E', 10)
await vi.runAllTimersAsync()
await playPromise
expect(ended).toBe(true)
})
it('stop() prevents further signals', async () => {
const starts: string[] = []
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
engine.stop()
const playPromise = engine.play('SOS', 10)
await vi.runAllTimersAsync()
await playPromise
expect(starts).toHaveLength(0)
})
it('can register and remove event listeners', () => {
const cb = vi.fn()
engine.on('play-end', cb)
engine.off('play-end', cb)
// No assertion needed — just verify no errors thrown
})
})
+115
View File
@@ -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')
}
}