cf2081f643
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>
122 lines
4.1 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|