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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+21
-15
@@ -7,11 +7,11 @@ 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).
|
||||
// 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,25 +33,28 @@ 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<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
|
||||
// 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
|
||||
|
||||
try {
|
||||
const ctx = this.getCtx()
|
||||
if (ctx.state === 'suspended') await ctx.resume()
|
||||
|
||||
@@ -111,5 +114,8 @@ export class MorseAudioEngine {
|
||||
|
||||
await new Promise<void>(resolve => setTimeout(resolve, totalMs))
|
||||
if (!isStopped()) this.emit('play-end')
|
||||
} finally {
|
||||
this.isPlaying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user