diff --git a/client/src/lib/audio.test.ts b/client/src/lib/audio.test.ts index a606895..b96277b 100644 --- a/client/src/lib/audio.test.ts +++ b/client/src/lib/audio.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { MorseAudioEngine } from './audio' describe('MorseAudioEngine', () => { @@ -9,6 +9,10 @@ describe('MorseAudioEngine', () => { vi.useFakeTimers() }) + afterEach(() => { + vi.useRealTimers() + }) + it('emits signal-start and signal-end events when playing E (.)', async () => { const starts: string[] = [] const ends: string[] = [] @@ -34,7 +38,7 @@ describe('MorseAudioEngine', () => { expect(ended).toBe(true) }) - it('stop() prevents further signals', async () => { + it('stop() before play() prevents signal emission', async () => { const starts: string[] = [] engine.on('signal-start', ({ type }: { type: string }) => starts.push(type)) @@ -46,10 +50,31 @@ describe('MorseAudioEngine', () => { expect(starts).toHaveLength(0) }) - it('can register and remove event listeners', () => { + it('play() works normally after a stop-before-play suppression', async () => { + const starts: string[] = [] + + // First play is suppressed + engine.stop() + await engine.play('SOS', 10) + + // Second play should proceed normally + engine.on('signal-start', ({ type }: { type: string }) => starts.push(type)) + const playPromise = engine.play('E', 10) + await vi.runAllTimersAsync() + await playPromise + + expect(starts).toEqual(['dot']) + }) + + it('off() removes listener so it is not called', async () => { const cb = vi.fn() engine.on('play-end', cb) engine.off('play-end', cb) - // No assertion needed — just verify no errors thrown + + const playPromise = engine.play('E', 10) + await vi.runAllTimersAsync() + await playPromise + + expect(cb).not.toHaveBeenCalled() }) }) diff --git a/client/src/lib/audio.ts b/client/src/lib/audio.ts index fa64cdb..44377ca 100644 --- a/client/src/lib/audio.ts +++ b/client/src/lib/audio.ts @@ -7,11 +7,11 @@ 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). + // stop() increments stopGeneration. play() captures it to detect mid-play cancellation. private stopGeneration = 0 - private activePlayId = 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') { @@ -33,83 +33,89 @@ export class MorseAudioEngine { } 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 { - // 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 + // 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 } - const myPlayId = this.activePlayId - const isStopped = () => this.stopGeneration !== myPlayId + this.isPlaying = true + const myGen = this.stopGeneration + const isStopped = () => this.stopGeneration !== myGen - const ctx = this.getCtx() - if (ctx.state === 'suspended') await ctx.resume() + 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 }> = [] + 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) { + for (const char of text.toUpperCase()) { if (isStopped()) break - if (!firstElement) scheduleTime += t.elementGap / 1000 - firstElement = false + if (char === ' ') { + scheduleTime += t.wordGap / 1000 + continue + } + const pattern = MORSE[char] + if (!pattern) continue - const isDot = el === '.' - const type: SignalType = isDot ? 'dot' : 'dash' - const dur = (isDot ? t.dot : t.dash) / 1000 + let firstElement = true + for (const el of pattern) { + if (isStopped()) break + if (!firstElement) scheduleTime += t.elementGap / 1000 + firstElement = false - // 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 isDot = el === '.' + const type: SignalType = isDot ? 'dot' : 'dash' + const dur = (isDot ? t.dot : t.dash) / 1000 - 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 } }) + // 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) - 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 } - 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 } - - 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') } }