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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+71
-65
@@ -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,83 +33,89 @@ 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
|
||||||
|
|
||||||
const ctx = this.getCtx()
|
try {
|
||||||
if (ctx.state === 'suspended') await ctx.resume()
|
const ctx = this.getCtx()
|
||||||
|
if (ctx.state === 'suspended') await ctx.resume()
|
||||||
|
|
||||||
const t = wpmToTiming(wpm)
|
const t = wpmToTiming(wpm)
|
||||||
let scheduleTime = ctx.currentTime + 0.05
|
let scheduleTime = ctx.currentTime + 0.05
|
||||||
const events: Array<{ delayMs: number; event: AudioEventName; data?: AudioEventPayload }> = []
|
const events: Array<{ delayMs: number; event: AudioEventName; data?: AudioEventPayload }> = []
|
||||||
|
|
||||||
for (const char of text.toUpperCase()) {
|
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 (isStopped()) break
|
||||||
if (!firstElement) scheduleTime += t.elementGap / 1000
|
if (char === ' ') {
|
||||||
firstElement = false
|
scheduleTime += t.wordGap / 1000
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const pattern = MORSE[char]
|
||||||
|
if (!pattern) continue
|
||||||
|
|
||||||
const isDot = el === '.'
|
let firstElement = true
|
||||||
const type: SignalType = isDot ? 'dot' : 'dash'
|
for (const el of pattern) {
|
||||||
const dur = (isDot ? t.dot : t.dash) / 1000
|
if (isStopped()) break
|
||||||
|
if (!firstElement) scheduleTime += t.elementGap / 1000
|
||||||
|
firstElement = false
|
||||||
|
|
||||||
// Schedule audio
|
const isDot = el === '.'
|
||||||
const osc = ctx.createOscillator()
|
const type: SignalType = isDot ? 'dot' : 'dash'
|
||||||
const gain = ctx.createGain()
|
const dur = (isDot ? t.dot : t.dash) / 1000
|
||||||
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
|
// Schedule audio
|
||||||
events.push({ delayMs: nowMs, event: 'signal-start', data: { type } })
|
const osc = ctx.createOscillator()
|
||||||
events.push({ delayMs: nowMs + dur * 1000, event: 'signal-end', data: { type } })
|
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<void>(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<void>(resolve => setTimeout(resolve, totalMs))
|
|
||||||
if (!isStopped()) this.emit('play-end')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user