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:
co-authored by
Claude Opus 4.6
parent
53ae4b485d
commit
a98d94d24c
@@ -12,7 +12,7 @@ import {
|
||||
prefetchQuestion, prefetchAnswer, prefetchNarration,
|
||||
awaitQuestionReady, awaitAnswerReady,
|
||||
playQuestionNow, playAnswerNow, playNarrationNow,
|
||||
sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage,
|
||||
sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage, sfxModem,
|
||||
} from '../game/audio'
|
||||
import { isKokoroLoading, getKokoroProgress } from '../game/tts'
|
||||
import { isPerfMode, setPerfMode } from '../game/fight/config'
|
||||
@@ -257,6 +257,16 @@ const challengeLabel = (type: string) => {
|
||||
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}`
|
||||
function sleep(ms: number): Promise<void> {
|
||||
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
|
||||
const isCodeRound = isCodeAnswer(round.challengeType, 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')
|
||||
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' })
|
||||
@@ -443,7 +454,10 @@ async function _doReplay() {
|
||||
}
|
||||
// Ensure bubble stays visible for at least 400ms even if TTS resolves instantly
|
||||
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
|
||||
if (!doTTS && !insanityMode.value) await sleep(400)
|
||||
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
|
||||
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')
|
||||
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' })
|
||||
@@ -464,7 +478,10 @@ async function _doReplay() {
|
||||
scene.startTalking('b')
|
||||
}
|
||||
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
|
||||
if (!doTTS && !insanityMode.value) await sleep(400)
|
||||
scene?.stopTalking('b')
|
||||
|
||||
@@ -160,6 +160,7 @@ export {
|
||||
sfxCrowdCheer,
|
||||
sfxApplause,
|
||||
sfxDrumRoll,
|
||||
sfxModem,
|
||||
announceCrowdReaction,
|
||||
} from './sfx'
|
||||
|
||||
|
||||
@@ -885,3 +885,54 @@ export function announceCrowdReaction(type: 'cheer' | 'gasp' | 'ooh' | 'applause
|
||||
const fns = { cheer: sfxCrowdCheer, gasp: sfxCrowdGasp, ooh: sfxCrowdOoh, applause: sfxApplause }
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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 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']
|
||||
export function announceDramatic(text: string) { speak(text, DRAMATIC_VOICES[Math.floor(Math.random() * DRAMATIC_VOICES.length)]) }
|
||||
export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)]) }
|
||||
export function announceSilly(text: string) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)]) }
|
||||
export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_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, cancel = false) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], cancel) }
|
||||
export function announceSilly(text: string, cancel = false) { speak(text, SILLY_VOICES[Math.floor(Math.random() * SILLY_VOICES.length)], cancel) }
|
||||
export function announceCool(text: string, cancel = false) { speak(text, COOL_VOICES[Math.floor(Math.random() * COOL_VOICES.length)], cancel) }
|
||||
|
||||
// === HYPE LINES ===
|
||||
|
||||
@@ -494,8 +494,8 @@ export function announceRoundHype() {
|
||||
|
||||
const CREATOR_VOICES = ['ancient', 'hal', 'mainframe', 'echo_v', 'final_boss', 'wizard_v', 'deep', 'preacher']
|
||||
|
||||
function announceCreator(text: string) {
|
||||
speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)])
|
||||
function announceCreator(text: string, cancel = false) {
|
||||
speak(text, CREATOR_VOICES[Math.floor(Math.random() * CREATOR_VOICES.length)], cancel)
|
||||
}
|
||||
|
||||
const CREATOR_ENTRANCE_LINES = [
|
||||
@@ -640,9 +640,9 @@ const CREATOR_DEVASTATING_LINES = [
|
||||
|
||||
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)]
|
||||
announceCreator(line)
|
||||
announceCreator(line, cancel)
|
||||
}
|
||||
|
||||
export function announceCreatorRound() {
|
||||
|
||||
@@ -87,7 +87,6 @@ async function playEntrance() {
|
||||
// Robe overlay
|
||||
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)])
|
||||
announceDeepIntro()
|
||||
// Slow walk in
|
||||
await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad)
|
||||
await k.wait(0.3)
|
||||
@@ -109,7 +108,7 @@ async function playEntrance() {
|
||||
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 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
|
||||
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
|
||||
@@ -322,7 +321,7 @@ async function playEntrance() {
|
||||
fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1
|
||||
// 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)])
|
||||
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)
|
||||
// Throw
|
||||
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 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
|
||||
announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!')
|
||||
announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!', true)
|
||||
sfxZoomWhoosh()
|
||||
await k.tween(startX, homeX, 0.5, (v) => {
|
||||
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
|
||||
// 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)])
|
||||
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) => {
|
||||
fighter.pos.x = v; spot.pos.x = v - 30
|
||||
}, k.easings.easeInOutQuad)
|
||||
@@ -494,7 +493,7 @@ async function playEntrance() {
|
||||
p.angle = Math.sin(k.time() * 3 + i) * 15
|
||||
})
|
||||
}
|
||||
announceCreatorEntrance()
|
||||
announceCreatorEntrance(true)
|
||||
rainDrops.forEach(r => { if (r.exists()) r.destroy() })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user