From f62490820a0a213035c5002e2fc040be45012397 Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 30 Apr 2026 00:23:30 +0000 Subject: [PATCH] feat: MorseAudioEngine with Web Audio API and event emitter Co-Authored-By: Claude Sonnet 4.6 --- client/src/lib/audio.test.ts | 55 +++++++++++++++++ client/src/lib/audio.ts | 115 +++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 client/src/lib/audio.test.ts create mode 100644 client/src/lib/audio.ts diff --git a/client/src/lib/audio.test.ts b/client/src/lib/audio.test.ts new file mode 100644 index 0000000..a606895 --- /dev/null +++ b/client/src/lib/audio.test.ts @@ -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 + }) +}) diff --git a/client/src/lib/audio.ts b/client/src/lib/audio.ts new file mode 100644 index 0000000..fa64cdb --- /dev/null +++ b/client/src/lib/audio.ts @@ -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 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 { + // 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(resolve => setTimeout(resolve, totalMs)) + if (!isStopped()) this.emit('play-end') + } +}