fix(audio): correct re-play-after-stop bug, strengthen tests

Use pendingCancel flag (consumed once) to suppress stop-before-play while
allowing subsequent play() calls to proceed normally. Add afterEach timer
cleanup, re-play test, and behavioral assertion for off().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:29:08 +00:00
parent f62490820a
commit cf2081f643
2 changed files with 100 additions and 69 deletions
+29 -4
View File
@@ -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' import { MorseAudioEngine } from './audio'
describe('MorseAudioEngine', () => { describe('MorseAudioEngine', () => {
@@ -9,6 +9,10 @@ describe('MorseAudioEngine', () => {
vi.useFakeTimers() vi.useFakeTimers()
}) })
afterEach(() => {
vi.useRealTimers()
})
it('emits signal-start and signal-end events when playing E (.)', async () => { it('emits signal-start and signal-end events when playing E (.)', async () => {
const starts: string[] = [] const starts: string[] = []
const ends: string[] = [] const ends: string[] = []
@@ -34,7 +38,7 @@ describe('MorseAudioEngine', () => {
expect(ended).toBe(true) expect(ended).toBe(true)
}) })
it('stop() prevents further signals', async () => { it('stop() before play() prevents signal emission', async () => {
const starts: string[] = [] const starts: string[] = []
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type)) engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
@@ -46,10 +50,31 @@ describe('MorseAudioEngine', () => {
expect(starts).toHaveLength(0) 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() const cb = vi.fn()
engine.on('play-end', cb) engine.on('play-end', cb)
engine.off('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()
}) })
}) })
+21 -15
View File
@@ -7,11 +7,11 @@ type AudioEventPayload = { type: SignalType } | undefined
export class MorseAudioEngine { export class MorseAudioEngine {
private ctx: AudioContext | null = null private ctx: AudioContext | null = null
private listeners = new Map<AudioEventName, Array<(data?: AudioEventPayload) => void>>() private listeners = new Map<AudioEventName, Array<(data?: AudioEventPayload) => void>>()
// Monotonically increasing. stop() increments it. play() records its value // stop() increments stopGeneration. play() captures it to detect mid-play cancellation.
// 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 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 { private getCtx(): AudioContext {
if (!this.ctx || this.ctx.state === 'closed') { if (!this.ctx || this.ctx.state === 'closed') {
@@ -33,25 +33,28 @@ export class MorseAudioEngine {
} }
stop(): void { 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.stopGeneration++
this.ctx?.suspend() this.ctx?.suspend()
} }
async play(text: string, wpm = 10): Promise<void> { async play(text: string, wpm = 10): Promise<void> {
// To allow playback, we align activePlayId with stopGeneration. // Consume a pending pre-play cancellation (stop() was called before this play()).
// But only do this if stop() was NOT called before this play() invocation. // After consuming, the flag is clear so subsequent play() calls proceed normally.
// We detect "stop-before-play" by checking if stopGeneration > activePlayId if (this.pendingCancel) {
// (meaning stop was called more recently than the last play start). this.pendingCancel = false
// If so, we do NOT reset, so isStopped() remains true throughout. return
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 this.isPlaying = true
const isStopped = () => this.stopGeneration !== myPlayId const myGen = this.stopGeneration
const isStopped = () => this.stopGeneration !== myGen
try {
const ctx = this.getCtx() const ctx = this.getCtx()
if (ctx.state === 'suspended') await ctx.resume() if (ctx.state === 'suspended') await ctx.resume()
@@ -111,5 +114,8 @@ export class MorseAudioEngine {
await new Promise<void>(resolve => setTimeout(resolve, totalMs)) await new Promise<void>(resolve => setTimeout(resolve, totalMs))
if (!isStopped()) this.emit('play-end') if (!isStopped()) this.emit('play-end')
} finally {
this.isPlaying = false
}
} }
} }