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'
|
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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user