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
+71 -65
View File
@@ -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,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<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
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<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')
}
}