feat: 56k modem SFX for code answers, fix crossed entrance voices

- Add sfxModem() — synthesized 56k handshake sound with carrier tones,
  data burst, and chirps. Plays instead of TTS for code_golf/hack_battle
  rounds and code-detected answers.
- Fix entrance voice overlap: remove duplicate announceDeepIntro() from
  robe entrance, add cancelPrevious to entrance-specific voice calls
  (girlfriend, bouncer, shopping cart, spotlight, creator) so they
  cleanly replace the global intro instead of overlapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 14:21:58 +00:00
co-authored by Claude Opus 4.6
parent 53ae4b485d
commit a98d94d24c
5 changed files with 87 additions and 19 deletions
+22 -5
View File
@@ -12,7 +12,7 @@ import {
prefetchQuestion, prefetchAnswer, prefetchNarration, prefetchQuestion, prefetchAnswer, prefetchNarration,
awaitQuestionReady, awaitAnswerReady, awaitQuestionReady, awaitAnswerReady,
playQuestionNow, playAnswerNow, playNarrationNow, playQuestionNow, playAnswerNow, playNarrationNow,
sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, sfxModem,
} from '../game/audio' } from '../game/audio'
import { isKokoroLoading, getKokoroProgress } from '../game/tts' import { isKokoroLoading, getKokoroProgress } from '../game/tts'
import { isPerfMode, setPerfMode } from '../game/fight/config' import { isPerfMode, setPerfMode } from '../game/fight/config'
@@ -257,6 +257,16 @@ const challengeLabel = (type: string) => {
return labels[type] || type.replace(/_/g, ' ').toUpperCase() return labels[type] || type.replace(/_/g, ' ').toUpperCase()
} }
const CODE_CHALLENGE_TYPES = new Set(['code_golf', 'hack_battle'])
function isCodeAnswer(challengeType: string, answer: string): boolean {
if (CODE_CHALLENGE_TYPES.has(challengeType)) return true
// Heuristic: code-like tokens in the answer
const codeTokens = ['=>', '===', '!==', '&&', '||', '++', '--', '{}', '();', 'function ', 'return ', 'const ', 'let ', 'var ', 'def ', 'import ', 'class ']
let hits = 0
for (const t of codeTokens) { if (answer.includes(t)) hits++ }
return hits >= 2
}
const tierClass = (t: number) => `tier-${t}` const tierClass = (t: number) => `tier-${t}`
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -431,8 +441,9 @@ async function _doReplay() {
} }
// 2. Bot A: ensure audio ready, then show log + bubble + mouth + voice all at once // 2. Bot A: ensure audio ready, then show log + bubble + mouth + voice all at once
const isCodeRound = isCodeAnswer(round.challengeType, round.botAResponse || '')
if (round.botAResponse) { if (round.botAResponse) {
if (doTTS) await awaitAnswerReady(props.fight.botA!.name, round.botAResponse) if (doTTS && !isCodeRound) await awaitAnswerReady(props.fight.botA!.name, round.botAResponse)
scene?.stopShowboating('a') scene?.stopShowboating('a')
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse || '[NO RESPONSE]'}`, color: 'neon-cyan' }) logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse || '[NO RESPONSE]'}`, color: 'neon-cyan' })
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
@@ -443,7 +454,10 @@ async function _doReplay() {
} }
// Ensure bubble stays visible for at least 400ms even if TTS resolves instantly // Ensure bubble stays visible for at least 400ms even if TTS resolves instantly
const minBubbleA = sleep(400).catch(() => {}) const minBubbleA = sleep(400).catch(() => {})
if (doTTS) await playAnswerNow(props.fight.botA!.name, round.botAResponse) if (doTTS) {
if (isCodeRound) await sfxModem()
else await playAnswerNow(props.fight.botA!.name, round.botAResponse)
}
await minBubbleA await minBubbleA
if (!doTTS && !insanityMode.value) await sleep(400) if (!doTTS && !insanityMode.value) await sleep(400)
scene?.stopTalking('a') scene?.stopTalking('a')
@@ -452,7 +466,7 @@ async function _doReplay() {
// 3. Bot B: ensure audio ready, then show log + bubble + mouth + voice all at once // 3. Bot B: ensure audio ready, then show log + bubble + mouth + voice all at once
if (round.botBResponse) { if (round.botBResponse) {
if (doTTS) await awaitAnswerReady(props.fight.botB!.name, round.botBResponse) if (doTTS && !isCodeRound) await awaitAnswerReady(props.fight.botB!.name, round.botBResponse)
scene?.stopShowboating('b') scene?.stopShowboating('b')
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse || '[NO RESPONSE]'}`, color: 'neon-pink' }) logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse || '[NO RESPONSE]'}`, color: 'neon-pink' })
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
@@ -464,7 +478,10 @@ async function _doReplay() {
scene.startTalking('b') scene.startTalking('b')
} }
const minBubbleB = sleep(400).catch(() => {}) const minBubbleB = sleep(400).catch(() => {})
if (doTTS) await playAnswerNow(props.fight.botB!.name, round.botBResponse) if (doTTS) {
if (isCodeRound) await sfxModem()
else await playAnswerNow(props.fight.botB!.name, round.botBResponse)
}
await minBubbleB await minBubbleB
if (!doTTS && !insanityMode.value) await sleep(400) if (!doTTS && !insanityMode.value) await sleep(400)
scene?.stopTalking('b') scene?.stopTalking('b')
+1
View File
@@ -160,6 +160,7 @@ export {
sfxCrowdCheer, sfxCrowdCheer,
sfxApplause, sfxApplause,
sfxDrumRoll, sfxDrumRoll,
sfxModem,
announceCrowdReaction, announceCrowdReaction,
} from './sfx' } from './sfx'
+51
View File
@@ -885,3 +885,54 @@ export function announceCrowdReaction(type: 'cheer' | 'gasp' | 'ooh' | 'applause
const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause } const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause }
fns[type]() fns[type]()
} }
/** 56k modem handshake — bleeps, carrier tones, and white noise bursts */
export function sfxModem(durationMs = 1800): Promise<void> {
const c = getCtx()
const d = getSfxDest()
const t = c.currentTime
const dur = durationMs / 1000
// Phase 1: Initial dial tone beeps (0-0.3s)
tone(2600, 'sine', 0.08, d, t)
tone(2400, 'sine', 0.08, d, t + 0.1)
tone(2600, 'sine', 0.06, d, t + 0.2)
// Phase 2: Carrier negotiation — rapid alternating tones (0.3-0.8s)
const freqs = [1200, 2400, 1800, 2100, 1650, 2250, 1950, 2400, 1200, 2100]
for (let i = 0; i < freqs.length; i++) {
tone(freqs[i], 'square', 0.04, d, t + 0.3 + i * 0.05)
}
// Phase 3: Data burst — filtered white noise (0.8-1.4s)
const noiseLen = Math.max(1, Math.floor(c.sampleRate * 0.6))
const noiseBuf = c.createBuffer(1, noiseLen, c.sampleRate)
const noiseData = noiseBuf.getChannelData(0)
for (let i = 0; i < noiseLen; i++) {
// Modulated noise — gives it that distinctive warbling character
noiseData[i] = (Math.random() * 2 - 1) * Math.sin(i * 0.02) * Math.cos(i * 0.005)
}
const noiseSrc = c.createBufferSource()
noiseSrc.buffer = noiseBuf
const noiseGain = c.createGain()
noiseGain.gain.setValueAtTime(0.12, t + 0.8)
noiseGain.gain.exponentialRampToValueAtTime(0.001, t + 1.4)
const bp = c.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 1800
bp.Q.value = 2
noiseSrc.connect(bp)
bp.connect(noiseGain)
noiseGain.connect(d)
noiseSrc.start(t + 0.8)
noiseSrc.stop(t + 1.4)
// Phase 4: Final handshake chirps (1.4-dur)
const chirpCount = Math.floor((dur - 1.4) / 0.08)
for (let i = 0; i < chirpCount; i++) {
const f = 1200 + Math.sin(i * 1.7) * 800
tone(f, 'sine', 0.04, d, t + 1.4 + i * 0.08)
}
return new Promise(resolve => setTimeout(resolve, durationMs))
}
+8 -8
View File
@@ -378,10 +378,10 @@ const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_
const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'wrestler_v', 'karen', 'terrified', 'power_up', 'news', 'scottish'] const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'wrestler_v', 'karen', 'terrified', 'power_up', 'news', 'scottish']
const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch', 'drunk', 'stoner', 'fairy', 'tutorial', 'npc', 'dial_up', 'glitchbot', 'grandma', 'conspiracy'] const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch', 'drunk', 'stoner', 'fairy', 'tutorial', 'npc', 'dial_up', 'glitchbot', 'grandma', 'conspiracy']
const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper', 'posh', 'aussie', 'french', 'sensei', 'ai_core', 'android_v', 'siri', 'professor', 'texan', 'boss_taunt', 'sleepy'] const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper', 'posh', 'aussie', 'french', 'sensei', 'ai_core', 'android_v', 'siri', 'professor', 'texan', 'boss_taunt', 'sleepy']
export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)]) } export function announceDramatic(text: string, cancel = false) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)], cancel) }
export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)]) } export function announceHype(text: string, cancel = false) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], cancel) }
export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) } export function announceSilly(text: string, cancel = false) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)], cancel) }
export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)]) } export function announceCool(text: string, cancel = false) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)], cancel) }
// === HYPE LINES === // === HYPE LINES ===
@@ -494,8 +494,8 @@ export function announceRoundHype() {
const CREATOR_VOICES = ['ancient', 'hal', 'mainframe', 'echo_v', 'final_boss', 'wizard_v', 'deep', 'preacher'] const CREATOR_VOICES = ['ancient', 'hal', 'mainframe', 'echo_v', 'final_boss', 'wizard_v', 'deep', 'preacher']
function announceCreator(text: string) { function announceCreator(text: string, cancel = false) {
speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)]) speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)], cancel)
} }
const CREATOR_ENTRANCE_LINES = [ const CREATOR_ENTRANCE_LINES = [
@@ -640,9 +640,9 @@ const CREATOR_DEVASTATING_LINES = [
const CREATOR_ANSWER_VOICES = ['hal', 'mainframe', 'echo_v', 'ancient', 'wizard_v'] const CREATOR_ANSWER_VOICES = ['hal', 'mainframe', 'echo_v', 'ancient', 'wizard_v']
export function announceCreatorEntrance() { export function announceCreatorEntrance(cancel = false) {
const line = CREATOR_ENTRANCE_LINES[Math.floor(Math.random() * CREATOR_ENTRANCE_LINES.length)] const line = CREATOR_ENTRANCE_LINES[Math.floor(Math.random() * CREATOR_ENTRANCE_LINES.length)]
announceCreator(line) announceCreator(line, cancel)
} }
export function announceCreatorRound() { export function announceCreatorRound() {
+5 -6
View File
@@ -87,7 +87,6 @@ async function playEntrance() {
// Robe overlay // Robe overlay
const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)] const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)]
const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(robeColor)), k.opacity(0.85), k.z(11)]) const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(robeColor)), k.opacity(0.85), k.z(11)])
announceDeepIntro()
// Slow walk in // Slow walk in
await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad) await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad)
await k.wait(0.3) await k.wait(0.3)
@@ -109,7 +108,7 @@ async function playEntrance() {
const gfX = startX + dir * 40 const gfX = startX + dir * 40
const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor('#ff69b4')), k.opacity(0.9), k.z(11)]) const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor('#ff69b4')), k.opacity(0.9), k.z(11)])
const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor('#ff0000')), k.z(12)]) const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor('#ff0000')), k.z(12)])
announceSilly('We need to talk! About your ELO!') announceSilly('We need to talk! About your ELO!', true)
// Walk in arguing // Walk in arguing
await k.tween(startX, homeX + dir * 30, 0.6, (v) => { await k.tween(startX, homeX + dir * 30, 0.6, (v) => {
fighter.pos.x = v; gf.pos.x = v + dir * 40; heart.pos.x = v + dir * 40 fighter.pos.x = v; gf.pos.x = v + dir * 40; heart.pos.x = v + dir * 40
@@ -322,7 +321,7 @@ async function playEntrance() {
fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
// Bouncer arm // Bouncer arm
const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor('#444444')), k.z(12)]) const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor('#444444')), k.z(12)])
announceSilly('And stay out! You\'re BANNED from the other fight!') announceSilly('And stay out! You\'re BANNED from the other fight!', true)
await k.wait(0.3) await k.wait(0.3)
// Throw // Throw
sfxZoomWhoosh() sfxZoomWhoosh()
@@ -381,7 +380,7 @@ async function playEntrance() {
const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor('#888888')), k.opacity(0.9), k.z(9)]) const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor('#888888')), k.opacity(0.9), k.z(9)])
const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor('#444')), k.z(9)]) const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor('#444')), k.z(9)])
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1 fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1
announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!') announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!', true)
sfxZoomWhoosh() sfxZoomWhoosh()
await k.tween(startX, homeX, 0.5, (v) => { await k.tween(startX, homeX, 0.5, (v) => {
fighter.pos.x = v; cartBody.pos.x = v; cartWheel.pos.x = v + (fromLeft ? 15 : -15) fighter.pos.x = v; cartBody.pos.x = v; cartWheel.pos.x = v + (fromLeft ? 15 : -15)
@@ -400,7 +399,7 @@ async function playEntrance() {
fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
// Spotlight cone // Spotlight cone
const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor('#ffe14d')), k.opacity(0.08), k.z(1)]) const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor('#ffe14d')), k.opacity(0.08), k.z(1)])
announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!') announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!', true)
await k.tween(startX, homeX, 1.0, (v) => { await k.tween(startX, homeX, 1.0, (v) => {
fighter.pos.x = v; spot.pos.x = v - 30 fighter.pos.x = v; spot.pos.x = v - 30
}, k.easings.easeInOutQuad) }, k.easings.easeInOutQuad)
@@ -494,7 +493,7 @@ async function playEntrance() {
p.angle = Math.sin(k.time() * 3 + i) * 15 p.angle = Math.sin(k.time() * 3 + i) * 15
}) })
} }
announceCreatorEntrance() announceCreatorEntrance(true)
rainDrops.forEach(r => { if (r.exists()) r.destroy() }) rainDrops.forEach(r => { if (r.exists()) r.destroy() })
} }