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 void>>() // stop() increments stopGeneration. play() captures it to detect mid-play cancellation. private stopGeneration = 0 // If stop() is called while no play is active, suppress exactly one upcoming play() call. private pendingCancel = false private isPlaying = false 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 { // Only suppress the next play() if nothing is currently playing. // If a play is in progress, stopGeneration increment is enough to cancel it. if (!this.isPlaying) { this.pendingCancel = true } this.stopGeneration++ this.ctx?.suspend() } async play(text: string, wpm = 10): Promise { // Consume a pending pre-play cancellation (stop() was called before this play()). // After consuming, the flag is clear so subsequent play() calls proceed normally. if (this.pendingCancel) { this.pendingCancel = false return } this.isPlaying = true const myGen = this.stopGeneration const isStopped = () => this.stopGeneration !== myGen try { 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(resolve => setTimeout(resolve, totalMs)) if (!isStopped()) this.emit('play-end') } finally { this.isPlaying = false } } }