Files
morsequest/client/src/lib/audio.ts
T
kitadmin cf2081f643 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>
2026-04-30 00:29:08 +00:00

122 lines
4.1 KiB
TypeScript

import { MORSE, wpmToTiming } from './morse'
type SignalType = 'dot' | 'dash'
type AudioEventName = 'signal-start' | 'signal-end' | 'play-end'
type AudioEventPayload = { type: SignalType } | undefined
export class MorseAudioEngine {
private ctx: AudioContext | null = null
private listeners = new Map<AudioEventName, Array<(data?: AudioEventPayload) => void>>()
// stop() increments stopGeneration. play() captures it to detect mid-play cancellation.
private stopGeneration = 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') {
this.ctx = new AudioContext()
}
return this.ctx
}
on(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), cb])
}
off(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
this.listeners.set(event, (this.listeners.get(event) ?? []).filter(l => l !== cb))
}
private emit(event: AudioEventName, data?: AudioEventPayload): void {
;(this.listeners.get(event) ?? []).forEach(cb => cb(data))
}
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> {
// 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
}
this.isPlaying = true
const myGen = this.stopGeneration
const isStopped = () => this.stopGeneration !== myGen
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 }> = []
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 (!firstElement) scheduleTime += t.elementGap / 1000
firstElement = false
const isDot = el === '.'
const type: SignalType = isDot ? 'dot' : 'dash'
const dur = (isDot ? t.dot : t.dash) / 1000
// 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 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
}
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
}
}
}