From c87bff01a82b000fa5d4e582fa22399ace4bd144 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 7 Mar 2026 11:42:32 +0000 Subject: [PATCH] feat: speech overflow fix, 60+ voice profiles, post-fight audio cleanup - Change speak() default to cancelPrevious=true so voices don't queue endlessly - Add stopAllAudio() export and wire into FightViewer unmount + post-fight - Flush speech queue at fight end with delayed cancel for clean cutoff - Expand from 30 to 60+ voice profiles (robots, accents, game, characters) - Add speech bubbles showing bot responses during rounds - Wire announceFinishHim/Fatality/FlawlessVictory/Devastating to not cancel Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/FightViewer.vue | 52 ++- frontend/src/game/FightScene.ts | 189 ++++++++- frontend/src/game/sounds.ts | 540 ++++++++++++++++++------ frontend/src/pages/BotProfilePage.vue | 6 +- frontend/src/pages/FightPage.vue | 228 +++++++--- server/src/routes/fights.ts | 6 + 6 files changed, 819 insertions(+), 202 deletions(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 401b2c2..f87c658 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -4,9 +4,9 @@ import { createFightScene, type FightSceneController } from '../game/FightScene' import { fanfareRound, fanfareFight, announce, announceDeep, announceFast, announceDeepIntro, announceRandomHype, announceRoundHype, - announceFinishHim, announceFatality, announceFlawlessVictory, + announceFinishHim, announceFlawlessVictory, sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll, - setMusicIntensity, + setMusicIntensity, stopAllAudio, } from '../game/sounds' interface Round { @@ -38,6 +38,7 @@ interface FightData { } const props = defineProps<{ fight: FightData; autoplay?: boolean }>() +const emit = defineEmits<{ 'replay-done': [] }>() const canvasRef = ref() const logEl = ref() @@ -65,8 +66,7 @@ const logItems = ref<{ type: string; round: number; text: string; color: string onMounted(async () => { if (props.autoplay) { - // Fresh fight — start replay immediately instead of showing static result - await initScene() + // Fresh fight — replay() will call initScene(), no need to double-init await nextTick() replay() } else { @@ -85,6 +85,7 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined): } onUnmounted(() => { + stopAllAudio() if (scene) { scene.destroy(); scene = null } }) @@ -240,17 +241,30 @@ async function replay() { const aWon = round.winnerId === props.fight.botA!.id const bWon = round.winnerId === props.fight.botB!.id - await scene!.playRound({ - round: round.roundNumber, - challengeType: round.challengeType, - winnerId: round.winnerId, - botAId: props.fight.botA!.id, - botBId: props.fight.botB!.id, - narration: round.narration || '', - isCritical, - botAScore: round.botAScore || 0, - botBScore: round.botBScore || 0, - }) + try { + await scene!.playRound({ + round: round.roundNumber, + challengeType: round.challengeType, + winnerId: round.winnerId, + botAId: props.fight.botA!.id, + botBId: props.fight.botB!.id, + narration: round.narration || '', + isCritical, + botAScore: round.botAScore || 0, + botBScore: round.botBScore || 0, + }) + } catch (err) { + console.error(`[FightViewer] playRound ${round.roundNumber} error:`, err) + } + + // Speech bubbles showing bot responses + if (scene) { + if (round.botAResponse) scene.showSpeechBubble('a', round.botAResponse.slice(0, 100), 2.8) + // Stagger bot B slightly so they don't pop in at the exact same time + setTimeout(() => { + if (scene && round.botBResponse) scene.showSpeechBubble('b', round.botBResponse.slice(0, 100), 2.5) + }, 300) + } // Hit text overlay const hitWords = isCritical @@ -323,6 +337,8 @@ async function replay() { displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id) scene?.stopMusic() + // Flush any queued speech from mid-fight so only final lines play + if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() // Always end with a dramatic death/KO sequence if (scene) { @@ -370,7 +386,13 @@ async function replay() { } } + // Kill any remaining queued speech — fight is over + setTimeout(() => { + if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() + }, 3000) + isReplaying.value = false + emit('replay-done') } diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 5149bc4..82cd310 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -566,6 +566,7 @@ export async function createFightScene(config: FightSceneConfig) { // Grotesque close-up stubs (disabled — looked bad) function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {} + function destroyGrotesqueDetails() {} // Arena-specific background decoration function drawArenaDecor() { const a = arena @@ -6039,7 +6040,187 @@ export async function createFightScene(config: FightSceneConfig) { ] // Sanitize text for Kaplay (treats [ ] as styled text tags) - function safeText(t: string): string { return t.replace(/[\[\]]/g, '') } + function safeText(t: string): string { + return t + .replace(/[\[\]{}<>`\\|~^]/g, '') // Kaplay styled-text tags and problematic chars + .replace(/[\x00-\x1f\x7f]/g, ' ') // Control characters → space + .replace(/[^\x20-\x7e]/g, '') // Strip non-ASCII (emoji, unicode) — Kaplay can't render them + .replace(/\s+/g, ' ') // Collapse whitespace + .trim() + } + + // Speech bubble above a fighter — colourful with tail + // Active speech bubbles — destroy previous before showing new + let activeBubbleA: any[] = [] + let activeBubbleB: any[] = [] + + function destroyBubble(els: any[]) { + els.forEach(el => { if (el.exists()) el.destroy() }) + els.length = 0 + } + + function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = 3) { + const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] + if (!fighter || !text) return + + // Kill previous bubble on this side + if (side === 'a') destroyBubble(activeBubbleA) + else destroyBubble(activeBubbleB) + + const allEls: any[] = [] + + // Sanitize and truncate + const clean = safeText(text) + if (!clean) return + const maxLen = 80 + const display = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '...' : clean + const lines = wrapBubbleText(display, 18) + const fontSize = 12 + const lineH = fontSize + 4 + const padX = 10, padY = 8 + const textW = Math.max(60, Math.min(lines.reduce((m, l) => Math.max(m, l.length * (fontSize * 0.6)), 0) + 8, W * 0.38)) + const bubbleW = textW + padX * 2 + const bubbleH = lines.length * lineH + padY * 2 + const tailSize = 10 + const borderW = 3 + + // Position — offset away from center so bubbles don't overlap + const offsetX = side === 'a' ? -bubbleW * 0.3 : bubbleW * 0.3 + const bx = fighter.pos.x + offsetX + const by = Math.max(8, fighter.pos.y - 85 - bubbleH) + + // Colours — vivid neon + const bgColor = side === 'a' ? '#001a22' : '#22001a' + const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b' + const glowColor = side === 'a' ? '#00f0ff' : '#ff2d7b' + const textColor = '#ffffff' + + // Outer glow halo + const glow = k.add([ + k.rect(bubbleW + 12, bubbleH + 12), + k.pos(bx - bubbleW / 2 - 6, by - 6), + k.color(safeColor(k, glowColor)), k.opacity(0.08), k.z(53), + ]) + allEls.push(glow) + + // Border (thick, behind bubble) + const border = k.add([ + k.rect(bubbleW + borderW * 2, bubbleH + borderW * 2), + k.pos(bx - bubbleW / 2 - borderW, by - borderW), + k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54), + ]) + allEls.push(border) + + // Bubble body + const bubble = k.add([ + k.rect(bubbleW, bubbleH), + k.pos(bx - bubbleW / 2, by), + k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55), + ]) + allEls.push(bubble) + + // Tail — two overlapping rects to form a triangle look + const tailX = bx + (side === 'a' ? bubbleW * 0.15 : -bubbleW * 0.15) + const tailY = by + bubbleH + // Outer tail (border colour) + const tailOuter = k.add([ + k.rect(tailSize + borderW, tailSize + borderW), + k.pos(tailX, tailY - 2), + k.color(safeColor(k, borderColor)), k.opacity(0.85), k.z(54), + k.rotate(45), k.anchor('top'), + ]) + allEls.push(tailOuter) + // Inner tail (bg colour, masks the border) + const tailInner = k.add([ + k.rect(tailSize, tailSize), + k.pos(tailX, tailY - 1), + k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(55), + k.rotate(45), k.anchor('top'), + ]) + allEls.push(tailInner) + // Cover strip — hides top half of the rotated tail square + const tailCover = k.add([ + k.rect(bubbleW, borderW + 2), + k.pos(bx - bubbleW / 2, tailY - borderW), + k.color(safeColor(k, bgColor)), k.opacity(0.95), k.z(56), + ]) + allEls.push(tailCover) + + // Inner accent line at top of bubble + const accent = k.add([ + k.rect(bubbleW - 8, 2), + k.pos(bx - bubbleW / 2 + 4, by + 3), + k.color(safeColor(k, borderColor)), k.opacity(0.4), k.z(56), + ]) + allEls.push(accent) + + // Text lines + for (let i = 0; i < lines.length; i++) { + const tEl = k.add([ + k.text(safeText(lines[i]), { size: fontSize }), + k.pos(bx - bubbleW / 2 + padX, by + padY + i * lineH + 2), + k.color(safeColor(k, textColor)), k.opacity(1), k.z(57), + ]) + allEls.push(tEl) + } + + // Animated glow pulse + glow.onUpdate(() => { + glow.opacity = 0.05 + Math.sin(k.time() * 3) * 0.05 + }) + border.onUpdate(() => { + border.opacity = 0.7 + Math.sin(k.time() * 4) * 0.15 + }) + + // Pop-in — scale up from anchor point with bounce + const anchorX = bx, anchorY = by + bubbleH + allEls.forEach(el => { + const origX = el.pos.x, origY = el.pos.y + el.pos.x = anchorX + (origX - anchorX) * 0.1 + el.pos.y = anchorY + (origY - anchorY) * 0.1 + el.opacity *= 0 + k.tween(0, 1, 0.25, (t) => { + el.pos.x = anchorX + (origX - anchorX) * t + el.pos.y = anchorY + (origY - anchorY) * t + el.opacity = (el === glow ? 0.08 : el === border ? 0.85 : el === bubble || el === tailInner || el === tailCover ? 0.95 : el === tailOuter ? 0.85 : el === accent ? 0.4 : 1) * t + }, k.easings.easeOutBack) + }) + + // Store reference + if (side === 'a') activeBubbleA = allEls + else activeBubbleB = allEls + + // Fade out after duration + setTimeout(() => { + allEls.forEach(el => { + if (!el.exists()) return + k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => { + if (el.exists()) el.destroy() + }) + }) + }, duration * 1000) + } + + function wrapBubbleText(text: string, maxChars: number): string[] { + const words = text.split(' ') + const lines: string[] = [] + let line = '' + for (const word of words) { + if (line.length + word.length + 1 > maxChars && line.length > 0) { + lines.push(line) + line = word + } else { + line = line ? line + ' ' + word : word + } + } + if (line) lines.push(line) + // Max 5 lines, last line truncated + if (lines.length > 5) { + const truncated = [...lines.slice(0, 4), lines.slice(4).join(' ').slice(0, maxChars - 3) + '...'] + return truncated + } + return lines + } // Spawn floating text above a position function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) { @@ -6363,6 +6544,10 @@ export async function createFightScene(config: FightSceneConfig) { async showAnnouncement(_text: string, _color: string = '#ffffff', _duration: number = 1200) {}, + showSpeechBubble(side: 'a' | 'b', text: string, duration?: number) { + showSpeechBubble(side, text, duration) + }, + async playEntrance() { const fA = k.get('fighterA')[0] const fB = k.get('fighterB')[0] @@ -7926,7 +8111,7 @@ export async function createFightScene(config: FightSceneConfig) { // PIZZA CUTTER — the big wheel fatalityTagline = 'SLICED!' announceFast('Extra large, extra lethal!') - const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor(k, '#cccccc')), k.z(18)]) + const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor(k, '#cccccc')), k.z(18), k.rotate(0)]) const handle = k.add([k.rect(6, 20), k.pos(winner.pos.x + dir * 30, GROUND_Y - 36), k.color(safeColor(k, '#553322')), k.z(17)]) sfxPowerUp(); winner.play('special') sfxZoomWhoosh() diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 1af4d49..ba58367 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -114,6 +114,45 @@ const voiceProfiles: Record = { glitch: { voice: null, pitch: 1.0, rate: 1.8, volume: 0.8 }, // Stuttery fast echo_v: { voice: null, pitch: 0.9, rate: 0.7, volume: 0.9 }, // Reverb cave voice hyper: { voice: null, pitch: 1.4, rate: 2.0, volume: 1.0 }, // Maximum speed maximum hype + // Robots & computers + mech: { voice: null, pitch: 0.5, rate: 0.9, volume: 1.0 }, // Heavy mech unit + ai_core: { voice: null, pitch: 0.9, rate: 1.0, volume: 0.8 }, // Calm AI assistant + dial_up: { voice: null, pitch: 1.7, rate: 1.5, volume: 0.7 }, // Squeaky modem era + mainframe: { voice: null, pitch: 0.3, rate: 0.5, volume: 1.0 }, // Deep supercomputer + android_v: { voice: null, pitch: 1.0, rate: 1.1, volume: 0.9 }, // Almost human android + glitchbot: { voice: null, pitch: 1.5, rate: 2.0, volume: 0.8 }, // Malfunctioning robot + siri: { voice: null, pitch: 1.2, rate: 1.0, volume: 0.9 }, // Polite digital assistant + hal: { voice: null, pitch: 0.6, rate: 0.6, volume: 0.9 }, // Menacing calm computer + // Old people & wise + grandma: { voice: null, pitch: 1.3, rate: 0.4, volume: 0.7 }, // Sweet slow grandma + professor: { voice: null, pitch: 0.8, rate: 0.7, volume: 0.8 }, // Lecturing academic + ancient: { voice: null, pitch: 0.4, rate: 0.3, volume: 0.6 }, // Ancient being, barely audible + sensei: { voice: null, pitch: 0.7, rate: 0.5, volume: 0.8 }, // Wise martial arts master + crotchety: { voice: null, pitch: 0.6, rate: 0.9, volume: 1.0 }, // Angry old man yelling + // Game-sounding + final_boss: { voice: null, pitch: 0.2, rate: 0.4, volume: 1.0 }, // Ultimate villain reveal + npc: { voice: null, pitch: 1.1, rate: 0.9, volume: 0.7 }, // Generic quest giver + tutorial: { voice: null, pitch: 1.3, rate: 1.1, volume: 0.8 }, // Annoying tutorial fairy + game_over: { voice: null, pitch: 0.5, rate: 0.7, volume: 1.0 }, // YOU DIED narrator + power_up: { voice: null, pitch: 1.6, rate: 1.4, volume: 1.0 }, // Excited power-up voice + boss_taunt: { voice: null, pitch: 0.4, rate: 0.8, volume: 1.0 }, // Boss mid-fight taunt + // Accents & character + posh: { voice: null, pitch: 1.0, rate: 0.7, volume: 0.9 }, // British upper class + aussie: { voice: null, pitch: 0.9, rate: 1.1, volume: 1.0 }, // Australian energy + scottish: { voice: null, pitch: 0.8, rate: 1.2, volume: 1.0 }, // Scottish intensity + french: { voice: null, pitch: 1.2, rate: 0.8, volume: 0.8 }, // French disdain + texan: { voice: null, pitch: 0.7, rate: 0.7, volume: 1.0 }, // Big Texan energy + // More characters + drunk: { voice: null, pitch: 0.9, rate: 0.5, volume: 0.8 }, // Slurring bar patron + sleepy: { voice: null, pitch: 0.8, rate: 0.3, volume: 0.5 }, // About to pass out + terrified: { voice: null, pitch: 1.8, rate: 1.7, volume: 1.0 }, // Absolutely panicking + giant: { voice: null, pitch: 0.1, rate: 0.4, volume: 1.0 }, // Fee fi fo fum + fairy: { voice: null, pitch: 2.0, rate: 1.3, volume: 0.6 }, // Tinkerbell energy + wrestler_v: { voice: null, pitch: 0.5, rate: 1.0, volume: 1.0 }, // WWE promo voice + karen: { voice: null, pitch: 1.4, rate: 1.5, volume: 1.0 }, // Wants to speak to manager + stoner: { voice: null, pitch: 0.9, rate: 0.4, volume: 0.6 }, // Whoooa duuude + news: { voice: null, pitch: 1.0, rate: 1.0, volume: 1.0 }, // Breaking news anchor + conspiracy: { voice: null, pitch: 1.1, rate: 1.3, volume: 0.7 }, // Wake up sheeple whisper } function loadVoices() { @@ -177,6 +216,48 @@ function loadVoices() { voiceProfiles.glitch.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || pick(0) voiceProfiles.echo_v.voice = findVoice([/daniel/i, /tom/i, /google.*uk/i]) || pick(2) voiceProfiles.hyper.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1) + // Robots & computers — prefer novelty/robotic voices + voiceProfiles.mech.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i, /bad news/i]) || pick(0) + voiceProfiles.ai_core.voice = findVoice([/samantha/i, /siri/i, /google.*us/i]) || pick(1) + voiceProfiles.dial_up.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen) + voiceProfiles.mainframe.voice = findVoice([/zarvox/i, /albert/i, /evan/i]) || pick(0) + voiceProfiles.android_v.voice = findVoice([/samantha.*enhanced/i, /google.*us/i, /evan.*premium/i]) || pick(1) + voiceProfiles.glitchbot.voice = findVoice([/trinoids/i, /zarvox/i, /bells/i]) || pick(3 % vLen) + voiceProfiles.siri.voice = findVoice([/samantha.*enhanced/i, /samantha/i, /google.*us.*female/i]) || pick(1) + voiceProfiles.hal.voice = findVoice([/daniel/i, /oliver/i, /google.*uk.*male/i]) || pick(2) + // Old people & wise — prefer deeper/slower voices + voiceProfiles.grandma.voice = findVoice([/samantha/i, /tessa/i, /karen/i, /female/i]) || pick(1) + voiceProfiles.professor.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) + voiceProfiles.ancient.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) + voiceProfiles.sensei.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2) + voiceProfiles.crotchety.voice = findVoice([/evan/i, /alex/i, /aaron/i]) || pick(0) + // Game-sounding + voiceProfiles.final_boss.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0) + voiceProfiles.npc.voice = findVoice([/samantha/i, /daniel/i, /google.*us/i]) || pick(1) + voiceProfiles.tutorial.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) + voiceProfiles.game_over.voice = findVoice([/evan/i, /tom/i, /daniel/i]) || pick(0) + voiceProfiles.power_up.voice = findVoice([/samantha/i, /karen/i, /google.*us/i]) || pick(1) + voiceProfiles.boss_taunt.voice = findVoice([/evan/i, /aaron/i, /zarvox/i]) || pick(0) + // Accents — try to find actual accent voices + const ukVoices = voices.filter(v => /en.gb|en.uk|en-GB|en-UK/i.test(v.lang)) + const auVoices = voices.filter(v => /en.au|en-AU/i.test(v.lang)) + const frVoices = voices.filter(v => /fr/i.test(v.lang)) + voiceProfiles.posh.voice = ukVoices[0] || findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || pick(2) + voiceProfiles.aussie.voice = auVoices[0] || findVoice([/karen/i, /lee/i]) || pick(2) + voiceProfiles.scottish.voice = findVoice([/fiona/i, /moira/i]) || ukVoices[1] || pick(2) + voiceProfiles.french.voice = frVoices[0] || findVoice([/thomas/i, /amelie/i]) || pick(3 % vLen) + voiceProfiles.texan.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) + // More characters + voiceProfiles.drunk.voice = findVoice([/evan/i, /tom/i, /alex/i]) || pick(0) + voiceProfiles.sleepy.voice = findVoice([/daniel/i, /oliver/i, /tom/i]) || pick(2) + voiceProfiles.terrified.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || pick(1) + voiceProfiles.giant.voice = findVoice([/evan/i, /aaron/i, /alex/i]) || pick(0) + voiceProfiles.fairy.voice = findVoice([/samantha/i, /bells/i, /karen/i]) || pick(1) + voiceProfiles.wrestler_v.voice = findVoice([/evan/i, /aaron/i, /james/i]) || pick(0) + voiceProfiles.karen.voice = findVoice([/samantha/i, /karen/i, /tessa/i]) || pick(1) + voiceProfiles.stoner.voice = findVoice([/evan/i, /tom/i, /oliver/i]) || pick(0) + voiceProfiles.news.voice = preferPremium([/evan.*premium/i, /google.*us/i, /james/i]) || pick(0) + voiceProfiles.conspiracy.voice = findVoice([/daniel/i, /tom/i, /whisper/i]) || pick(2) } if (typeof speechSynthesis !== 'undefined') { @@ -184,7 +265,7 @@ if (typeof speechSynthesis !== 'undefined') { loadVoices() } -function speak(text: string, profileName: string, cancelPrevious: boolean = true, echo: boolean = false) { +function speak(text: string, profileName: string, cancelPrevious: boolean = true, _echo: boolean = false) { if (typeof speechSynthesis === 'undefined') return if (!voicesLoaded) loadVoices() if (cancelPrevious) speechSynthesis.cancel() @@ -195,25 +276,11 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = true utter.rate = profile.rate utter.volume = profile.volume speechSynthesis.speak(utter) - // Echo effect: repeat at lower volume with slight delay - if (echo) { - setTimeout(() => { - const echo1 = new SpeechSynthesisUtterance(text) - if (profile.voice) echo1.voice = profile.voice - echo1.pitch = profile.pitch * 0.9 - echo1.rate = profile.rate * 0.95 - echo1.volume = profile.volume * 0.4 - speechSynthesis.speak(echo1) - }, 250) - setTimeout(() => { - const echo2 = new SpeechSynthesisUtterance(text) - if (profile.voice) echo2.voice = profile.voice - echo2.pitch = profile.pitch * 0.8 - echo2.rate = profile.rate * 0.9 - echo2.volume = profile.volume * 0.15 - speechSynthesis.speak(echo2) - }, 500) - } +} + +export function stopAllAudio() { + stopMusic() + if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() } // Public voice functions @@ -236,9 +303,9 @@ export function announce(text: string, pitch?: number, rate?: number) { } export function announceDeep(text: string) { speak(text, 'deep') } -export function announceFast(text: string) { speak(text, 'hype', false) } +export function announceFast(text: string) { speak(text, 'hype') } export function announceRobot(text: string) { speak(text, 'robot') } -export function announceScream(text: string) { speak(text, 'screamer', false) } +export function announceScream(text: string) { speak(text, 'screamer') } export function announceSmooth(text: string) { speak(text, 'smooth') } // Pick a random voice profile for variety @@ -248,12 +315,12 @@ export function announceRandom(text: string, echo: boolean = false) { speak(text, key, true, echo) } // Announce with a specific mood category -const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v'] -const HYPE_VOICES = ['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill'] -const SILLY_VOICES = ['chipmunk', 'baby', 'surfer', 'valley', 'pirate_v', 'alien_v', 'glitch'] -const COOL_VOICES = ['smooth', 'wizard_v', 'ninja_v', 'cowboy_v', 'angel', 'whisper'] +const DRAMATIC_VOICES = ['deep', 'boomer', 'movie', 'preacher', 'opera', 'demon_v', 'echo_v', 'final_boss', 'game_over', 'mainframe', 'hal', 'ancient', 'giant'] +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)], true, true) } -export function announceHype(text: string) { speak(text, HYPE_VOICES[Math.floor(Math.random() * HYPE_VOICES.length)], false) } +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)]) } @@ -335,15 +402,15 @@ const ROUND_HYPE = [ // Mortal Kombat style dramatic calls export function announceFinishHim() { - speak('Finish it!', 'announcer', true, true) + speak('Finish it!', 'announcer', false, true) } export function announceFatality(tagline?: string) { - speak(tagline || 'Fatality!', 'deep', true, true) + speak(tagline || 'Fatality!', 'deep', false, true) } export function announceFlawlessVictory() { - speak('Flawless victory!', 'deep', true, true) + speak('Flawless victory!', 'deep', false, true) } export function announceRandomHype() { @@ -399,7 +466,7 @@ export function fanfareDevastating() { tone(220, 'sawtooth', 0.2, d, t) tone(175, 'sawtooth', 0.3, d, t + 0.15) noise(0.15, d, t + 0.1) - setTimeout(() => speak('Devastating!', 'deep', true, true), 150) + setTimeout(() => speak('Devastating!', 'deep', false, true), 150) } export function fanfareCritical() { @@ -689,7 +756,7 @@ export function sfxKO() { }, 180) // Heavy reverb reverbTail(1.0, d, t + 0.05) - setTimeout(() => speak('K. O.!', 'announcer', true, true), 500) + setTimeout(() => speak('K. O.!', 'announcer', false, true), 500) } export function sfxPerfect() { @@ -1245,13 +1312,184 @@ const track4: MusicTrack = { } // === TRACK GENERATOR === -// Procedurally generate tracks from musical parameters for variety -function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number): MusicTrack { - // Deterministic RNG +// Genre types for distinct melodic DNA per style +type Genre = 'funk' | 'hiphop' | 'rock' | 'metal' | 'chiptune' | 'jazz' | 'electronic' | 'latin' | 'reggae' + +// Genre-specific bass patterns (each genre has its own feel) +const GENRE_BASS: Record = { + funk: [ + [0,0,0,4, 0,0,2,0, 3,0,0,5, 0,3,0,2], // syncopated slap bass + [0,0,4,0, 2,0,0,4, 0,3,0,5, 3,0,2,0], + [0,2,0,4, 0,0,5,0, 3,0,4,0, 2,0,0,4], + [0,0,0,0, 4,0,0,2, 0,0,3,0, 5,0,4,0], + ], + hiphop: [ + [0,0,0,0, 0,0,0,0, 3,0,0,0, 0,0,0,0], // deep sparse hits + [0,0,0,0, 2,0,0,0, 0,0,0,0, 4,0,0,0], + [0,0,0,2, 0,0,0,0, 0,0,3,0, 0,0,0,0], + [0,0,0,0, 0,0,2,0, 0,0,0,0, 0,0,4,2], + ], + rock: [ + [0,0,0,0, 2,2,0,0, 4,4,0,0, 2,2,0,0], // power chord root pumps + [0,0,2,2, 0,0,4,4, 0,0,5,5, 4,4,2,2], + [0,0,0,0, 0,0,0,0, 4,4,4,4, 2,2,0,0], + [0,2,4,2, 0,2,4,5, 4,2,0,2, 4,5,7,5], + ], + metal: [ + [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0], // tremolo root + [0,0,0,1, 0,0,0,1, 0,0,0,1, 0,0,0,1], // palm mute gallop + [0,0,3,0, 0,0,5,0, 0,0,3,0, 0,0,1,0], // staccato riff + [0,1,0,3, 0,1,0,5, 0,1,0,3, 5,3,1,0], // thrash riff + ], + chiptune: [ + [0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0], // classic bouncy + [0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2], + [0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0], + [0,0,4,7, 4,0,0,4, 3,3,7,10, 7,3,0,3], + ], + jazz: [ + [0,2,4,5, 7,5,4,2, 0,3,5,7, 9,7,5,3], // walking bass + [0,4,7,4, 2,5,9,5, 4,7,11,7, 5,4,2,0], + [0,1,2,3, 4,5,4,3, 2,3,4,5, 7,5,4,2], // chromatic walk + [0,3,7,3, 5,2,7,4, 0,4,7,4, 9,7,4,2], + ], + electronic: [ + [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0], // pulsing single note + [0,0,0,0, 4,4,4,4, 0,0,0,0, 7,7,7,7], // alternating octaves + [0,0,4,0, 0,0,7,0, 0,0,4,0, 0,0,2,0], + [0,4,0,7, 0,4,0,9, 0,4,0,7, 0,9,0,4], + ], + latin: [ + [0,0,0,4, 0,0,2,0, 0,0,0,4, 0,2,0,0], // tresillo bass + [0,0,0,3, 0,0,5,0, 0,0,0,3, 0,5,0,0], + [0,4,0,0, 7,0,0,4, 0,5,0,0, 7,0,4,0], + [0,0,4,0, 0,7,0,4, 0,0,5,0, 0,7,0,5], + ], + reggae: [ + [0,0,0,0, 0,0,0,0, 4,0,0,0, 0,0,0,0], // one-drop bass + [0,0,0,0, 0,0,0,0, 0,0,0,0, 4,0,2,0], + [0,0,0,0, 4,0,0,0, 0,0,0,0, 2,0,0,0], + [0,0,0,0, 0,0,4,0, 0,0,0,0, 0,0,2,0], + ], +} + +// Genre-specific lead patterns +const GENRE_LEAD: Record = { + funk: [ + [0,0,4,0, 0,7,0,4, 0,0,9,0, 0,7,0,0], // wah-wah stabs + [4,0,0,7, 0,0,4,0, 9,0,0,7, 0,0,4,0], + [0,7,0,0, 4,0,0,7, 0,9,0,0, 7,0,4,0], + [0,0,0,4, 7,0,0,0, 0,0,0,9, 7,4,0,0], + ], + hiphop: [ + [7,0,0,0, 4,0,0,0, 7,0,0,0, 9,0,0,0], // looping melodic hook + [4,0,7,0, 0,0,4,0, 9,0,7,0, 0,0,4,0], + [0,0,7,0, 0,0,9,0, 0,0,7,0, 0,0,4,0], + [7,4,0,0, 9,7,0,0, 7,4,0,0, 2,4,0,0], + ], + rock: [ + [0,2,4,7, 9,7,4,2, 0,2,4,7, 11,9,7,4], // pentatonic licks + [7,9,11,9, 7,4,2,4, 7,9,11,14, 11,9,7,4], + [0,4,7,0, 4,7,11,7, 4,0,4,7, 11,14,11,7], + [14,11,9,7, 4,2,0,2, 4,7,9,11, 14,11,9,7], + ], + metal: [ + [0,0,0,0, 12,11,0,0, 0,0,0,0, 7,5,0,0], // shred bursts + [0,0,12,14, 16,14,12,0, 0,0,7,9, 11,9,7,0], + [14,0,12,0, 11,0,9,0, 7,0,5,0, 4,0,2,0], // descending shred + [0,2,4,7, 0,2,4,9, 0,2,4,11, 12,11,9,7], // ascending runs + ], + chiptune: [ + [0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,-2], + [0,4,7,11, 9,7,4,2, 0,4,9,11, 14,11,9,7], + [7,9,11,14, 11,9,7,4, 9,11,14,16, 14,11,9,7], + [0,2,4,2, 7,4,2,0, 4,7,9,11, 9,7,4,2], + ], + jazz: [ + [0,2,4,5, 7,9,11,9, 7,5,4,2, 0,2,4,7], // bebop lines + [4,5,7,9, 11,9,7,5, 4,2,0,2, 4,7,9,11], + [0,1,2,4, 5,7,9,7, 5,4,2,1, 0,4,7,11], // chromatic approach + [7,9,11,12, 14,12,11,9, 7,5,4,5, 7,9,11,14], + ], + electronic: [ + [0,0,7,0, 0,0,7,0, 0,0,9,0, 0,0,7,0], // filter sweep feel + [0,4,7,4, 0,4,9,4, 0,4,11,4, 0,4,9,4], + [7,7,7,7, 9,9,9,9, 11,11,11,11, 9,9,9,9], // pulsing + [0,7,0,9, 0,11,0,9, 0,7,0,4, 0,7,0,9], + ], + latin: [ + [0,4,7,0, 4,7,9,7, 4,0,4,7, 9,7,4,0], // salsa piano + [7,0,9,0, 7,0,4,0, 7,0,9,0, 11,0,9,0], + [0,2,4,7, 0,2,4,9, 0,2,4,7, 9,7,4,2], + [4,7,9,4, 7,9,11,7, 9,11,14,9, 11,9,7,4], + ], + reggae: [ + [0,0,4,0, 0,0,4,0, 0,0,7,0, 0,0,4,0], // skank chops + [0,0,7,0, 0,0,7,0, 0,0,9,0, 0,0,7,0], + [0,0,4,7, 0,0,4,7, 0,0,4,9, 0,0,4,7], + [0,0,0,4, 0,0,0,7, 0,0,0,4, 0,0,0,2], + ], +} + +// Genre-specific arp patterns +const GENRE_ARP: Record = { + funk: [ + [0,4,7,0, 0,4,7,0, 0,4,9,0, 0,4,7,0], + [0,0,4,0, 7,0,4,0, 0,0,9,0, 7,0,4,0], + ], + hiphop: [ + [0,0,0,0, 7,0,0,0, 0,0,0,0, 4,0,0,0], // sparse melodic + [0,0,7,0, 0,0,0,0, 0,0,4,0, 0,0,0,0], + ], + rock: [ + [0,4,7,4, 0,4,7,4, 0,4,9,4, 0,4,7,4], // power chord arp + [0,7,4,7, 0,7,4,7, 0,9,4,9, 0,7,4,7], + ], + metal: [ + [0,0,7,0, 0,0,7,0, 0,0,7,0, 12,0,7,0], // tremolo picking + [0,7,12,7, 0,7,12,7, 0,7,14,7, 0,7,12,7], + ], + chiptune: [ + [0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,2], + [0,4,7,11, 7,4,0,4, 7,11,14,11, 7,4,0,4], + [0,7,4,11, 7,14,11,7, 4,11,7,14, 11,4,7,0], + ], + jazz: [ + [0,4,7,9, 11,9,7,4, 0,4,7,11, 14,11,7,4], + [0,2,4,7, 9,7,4,2, 0,4,9,11, 9,7,4,0], + ], + electronic: [ + [0,4,7,4, 0,4,7,4, 0,4,7,4, 0,4,7,4], // relentless arp + [0,7,4,11, 0,7,4,11, 0,9,4,11, 0,7,4,11], + [0,4,7,11, 14,11,7,4, 0,4,7,11, 14,11,7,4], + ], + latin: [ + [0,4,7,0, 4,7,9,7, 4,0,4,7, 9,7,4,0], + [0,7,4,7, 0,9,4,9, 0,7,4,7, 0,4,7,4], + ], + reggae: [ + [0,0,4,0, 0,0,7,0, 0,0,4,0, 0,0,2,0], + [0,0,7,0, 0,0,4,0, 0,0,7,0, 0,0,9,0], + ], +} + +// Genre-specific chord progressions (scale degrees for triads) +const GENRE_CHORDS: Record = { + funk: [0, 3, 0, 5, 0, 3, 5, 4], // i-iv-i-v groovy + hiphop: [0, 3, 4, 3, 0, 5, 4, 3], // dark minor loops + rock: [0, 5, 3, 4, 0, 5, 3, 4], // I-V-IV power progression + metal: [0, 1, 5, 4, 0, 1, 3, 0], // i-bII-v chromatic + chiptune: [0, 3, 2, 4, 0, 5, 3, 4], // classic game + jazz: [0, 3, 5, 1, 4, 2, 5, 4], // ii-V-I movement + electronic:[0, 4, 5, 4, 0, 3, 5, 3], // trance progression + latin: [0, 3, 4, 5, 0, 3, 4, 5], // son montuno + reggae: [0, 4, 0, 4, 0, 5, 0, 4], // one chord vibes +} + +function genTrack(name: string, bpm: number, root: number, scaleIntervals: number[], drumStyle: number[], seed: number, genre: Genre = 'chiptune'): MusicTrack { let s = seed const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff } - // Build scale note lookup: root freq across octaves const allNotes: number[] = [] for (let oct = -1; oct < 6; oct++) { for (const semi of scaleIntervals) { @@ -1260,77 +1498,92 @@ function genTrack(name: string, bpm: number, root: number, scaleIntervals: numbe } const sLen = scaleIntervals.length const note = (degree: number) => { - const idx = Math.max(0, Math.min(allNotes.length - 1, degree + sLen)) // offset by 1 octave + const idx = Math.max(0, Math.min(allNotes.length - 1, degree + sLen)) return allNotes[idx] } - // Bass: degrees 0-6, mostly stepwise, bar-level progressions - const bassProgs = [ - [0,0,2,4, 2,0,2,4, 3,3,5,7, 5,3,2,0], - [0,2,4,2, 0,4,5,4, 3,5,7,5, 3,2,0,2], - [0,0,0,2, 4,4,2,0, 3,3,3,5, 7,5,3,2], - [0,4,7,4, 0,2,5,2, 3,7,10,7, 5,4,2,0], - ] + // Pick genre-specific patterns + const bassPool = GENRE_BASS[genre] + const leadPool = GENRE_LEAD[genre] + const arpPool = GENRE_ARP[genre] + const chordDegs = GENRE_CHORDS[genre] + + // Bass const bass: number[] = [] for (let bar = 0; bar < BARS; bar++) { - const prog = bassProgs[bar % bassProgs.length] - const lift = Math.floor(bar / 2) // gradually ascend + const prog = bassPool[(bar + Math.floor(rng() * bassPool.length)) % bassPool.length] + const lift = genre === 'metal' ? 0 : Math.floor(bar / 3) for (let step = 0; step < STEPS; step++) { - bass.push(note(prog[step % prog.length] + lift)) + const d = prog[step % prog.length] + // Genre-specific spice + if (genre === 'funk' && d === 0 && rng() < 0.15) { + bass.push(note(Math.floor(rng() * 5) + lift)) // ghost notes + } else if (genre === 'metal' && d === 0) { + bass.push(note(0 + lift)) // palm mute on root + } else { + bass.push(note(d + lift)) + } } } - // Lead: degrees 7-20 (octave 2-4), melodic contours - const leadShapes = [ - [0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,-2], - [0,4,7,11, 9,7,4,2, 0,4,9,11, 14,11,9,7], - [7,9,11,14, 11,9,7,4, 9,11,14,16, 14,11,9,7], - [0,2,4,2, 7,4,2,0, 4,7,9,11, 9,7,4,2], - ] + // Lead const lead: number[] = [] for (let bar = 0; bar < BARS; bar++) { - const shape = leadShapes[bar % leadShapes.length] - const octShift = sLen + (bar >= 4 ? sLen : 0) // higher octave in second half + const shape = leadPool[(bar + Math.floor(rng() * leadPool.length)) % leadPool.length] + const octShift = sLen + (bar >= 4 ? sLen : 0) for (let step = 0; step < STEPS; step++) { const degree = shape[step % shape.length] + octShift - // Add slight variation - const vary = rng() < 0.15 ? (rng() < 0.5 ? 1 : -1) : 0 - lead.push(note(Math.max(sLen, degree + vary))) + const vary = rng() < 0.2 ? Math.floor(rng() * 3) - 1 : 0 + // Genre-specific: hiphop/reggae have silent steps + if ((genre === 'hiphop' || genre === 'reggae') && shape[step % shape.length] === 0) { + lead.push(0) + } else { + lead.push(note(Math.max(sLen, degree + vary))) + } } } - // Arp: degrees in octave 1-3, arpeggiated patterns - const arpPatterns = [ - [0,2,4,7, 4,2,0,2, 4,7,9,7, 4,2,0,2], - [0,4,7,11, 7,4,0,4, 7,11,14,11, 7,4,0,4], - [0,7,4,11, 7,14,11,7, 4,11,7,14, 11,4,7,0], - ] + // Arp const arp: number[] = [] for (let bar = 0; bar < BARS; bar++) { - const pat = arpPatterns[bar % arpPatterns.length] + const pat = arpPool[(bar + Math.floor(rng() * arpPool.length)) % arpPool.length] const shift = Math.floor(sLen * 0.5) + Math.floor(bar / 3) for (let step = 0; step < STEPS; step++) { - arp.push(note(pat[step % pat.length] + shift)) + const d = pat[step % pat.length] + if (d === 0 && (genre === 'hiphop' || genre === 'reggae' || genre === 'funk')) { + arp.push(0) + } else { + arp.push(note(d + shift)) + } } } - // Chords: triads from scale degrees - const chordDegs = [0, 3, 2, 4, 0, 5, 3, 4] + // Chords const chords: number[][] = [] for (const d of chordDegs) { - chords.push([note(d), note(d + 2), note(d + 4)]) + if (genre === 'jazz') { + chords.push([note(d), note(d + 2), note(d + 4), note(d + 6)]) // 7th chords + } else if (genre === 'rock' || genre === 'metal') { + chords.push([note(d), note(d + 4)]) // power chords (root + 5th) + } else { + chords.push([note(d), note(d + 2), note(d + 4)]) + } } - // Drums: base pattern repeated, with builds in later bars + // Drums with genre-specific fills const drums: number[] = [] for (let bar = 0; bar < BARS; bar++) { for (let step = 0; step < STEPS; step++) { let d = drumStyle[step % drumStyle.length] - // Build: add fills in bars 6-7 + // Build: fills in bars 6-7 if (bar >= 6 && step >= 12 && d === 3) d = 2 if (bar >= 7 && step >= 14) d = d === 3 ? 2 : d - // Extra hats in later bars - if (bar >= 4 && d === 0 && rng() < 0.2) d = 3 + // Genre-specific density + if (genre === 'metal' && d === 0 && rng() < 0.4) d = 3 // double bass fills + if (genre === 'rock' && bar >= 4 && d === 0 && rng() < 0.25) d = 3 + if (genre === 'funk' && d === 0 && rng() < 0.3) d = 3 // ghost note hats + if (genre === 'hiphop' && d === 0 && rng() < 0.1) d = 3 // sparse hats + if ((genre === 'chiptune' || genre === 'electronic' || genre === 'jazz' || genre === 'latin') && bar >= 4 && d === 0 && rng() < 0.2) d = 3 drums.push(d) } } @@ -1348,6 +1601,15 @@ const DRUMS_SWING = [1,0,3,0, 2,3,0,3, 1,0,3,1, 2,0,3,0] const DRUMS_HALFTIME = [1,0,0,0, 0,0,0,0, 2,0,0,0, 0,0,3,0] const DRUMS_DNB = [1,0,0,3, 0,0,2,0, 0,3,0,0, 2,3,1,3] +// Genre-specific drum patterns +const DRUMS_FUNK = [1,0,3,0, 2,3,0,3, 1,3,0,1, 2,0,3,0] // syncopated ghost notes +const DRUMS_HIPHOP = [1,0,0,3, 0,0,2,0, 1,0,3,0, 0,0,2,3] // boom bap +const DRUMS_ROCK = [1,3,2,3, 1,3,2,3, 1,3,2,3, 1,3,2,4] // driving backbeat +const DRUMS_REGGAE = [0,0,3,0, 2,0,3,0, 0,0,3,0, 2,0,3,0] // one drop +const DRUMS_BREAK = [1,0,1,3, 2,0,0,1, 0,3,1,0, 2,3,0,1] // chopped breakbeat +const DRUMS_LATIN = [1,0,0,1, 0,0,1,0, 1,0,0,1, 0,1,0,0] // tresillo +const DRUMS_SHUFFLE = [1,3,0,3, 2,0,3,3, 1,3,0,3, 2,3,4,3] // swung triplet feel + // Scale presets (semitone intervals) const SCALE_MINOR = [0,2,3,5,7,8,10] const SCALE_MAJOR = [0,2,4,5,7,9,11] @@ -1360,55 +1622,72 @@ const SCALE_PENTATONIC = [0,2,4,7,9] const SCALE_JAPANESE = [0,1,5,7,8] const SCALE_ARABIC = [0,1,4,5,7,8,11] -// Track 5: "Chill Lounge" — C major, relaxed, 155bpm -const track5 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42) +// === FUNK tracks — syncopated slap bass, wah stabs, ghost note drums === +const track5 = genTrack('Funk Machine', 200, 147, SCALE_MIXOLYDIAN, DRUMS_FUNK, 420, 'funk') +const track6 = genTrack('Funk Royale', 195, 131, SCALE_MIXOLYDIAN, DRUMS_FUNK, 1971, 'funk') +const track7 = genTrack('Soul Slugger', 185, 117, SCALE_DORIAN, DRUMS_FUNK, 1968, 'funk') +const track8 = genTrack('Funky Fists', 205, 208, SCALE_MIXOLYDIAN, DRUMS_FUNK, 1975, 'funk') +const track9 = genTrack('Slap City', 210, 165, SCALE_BLUES, DRUMS_FUNK, 1979, 'funk') -// Track 6: "Cyber Punk" — Bb minor, aggressive, 250bpm -const track6 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99) +// === HIP HOP tracks — deep sparse bass, looping hooks, boom bap === +const track10 = genTrack('Boom Bap Brawl', 170, 110, SCALE_MINOR, DRUMS_HIPHOP, 1994, 'hiphop') +const track11 = genTrack('Trap Arena', 155, 185, SCALE_MINOR, DRUMS_HIPHOP, 2012, 'hiphop') +const track12 = genTrack('Hip Hop Havoc', 175, 131, SCALE_MINOR, DRUMS_HIPHOP, 1988, 'hiphop') +const track13 = genTrack('Beat Down Blvd', 165, 147, SCALE_DORIAN, DRUMS_HIPHOP, 1996, 'hiphop') +const track14 = genTrack('Street Cypher', 180, 98, SCALE_BLUES, DRUMS_HIPHOP, 2004, 'hiphop') -// Track 7: "Retro Arcade" — G major, bouncy, 195bpm -const track7 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137) +// === ROCK tracks — power chord pumps, pentatonic licks, driving backbeat === +const track15 = genTrack('Stadium Rock', 210, 165, SCALE_MAJOR, DRUMS_ROCK, 1985, 'rock') +const track16 = genTrack('Garage Smasher', 220, 147, SCALE_MINOR, DRUMS_ROCK, 1969, 'rock') +const track17 = genTrack('Punk Blitz', 245, 165, SCALE_MAJOR, DRUMS_ROCK, 1977, 'rock') +const track18 = genTrack('Arena Anthem', 200, 131, SCALE_PENTATONIC, DRUMS_ROCK, 1987, 'rock') +const track19 = genTrack('Riff Rampage', 215, 110, SCALE_BLUES, DRUMS_ROCK, 1991, 'rock') -// Track 8: "Boss Battle" — D minor, epic, 240bpm -const track8 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256) +// === METAL tracks — palm mute gallops, shred bursts, double bass fills === +const track20 = genTrack('Metal Mayhem', 260, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666, 'metal') +const track21 = genTrack('Boss Battle', 240, 147, SCALE_HARMMINOR, DRUMS_HEAVY, 256, 'metal') +const track22 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793, 'metal') +const track23 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603, 'metal') +const track24 = genTrack('Skull Crusher', 270, 98, SCALE_PHRYGIAN, DRUMS_FRANTIC, 999, 'metal') -// Track 9: "Jazz Fusion" — Eb dorian, smooth, 175bpm -const track9 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333) +// === CHIPTUNE tracks — classic bouncy game music === +const track25 = genTrack('Retro Arcade', 195, 196, SCALE_MAJOR, DRUMS_GROOVE, 137, 'chiptune') +const track26 = genTrack('Chill Lounge', 155, 131, SCALE_MAJOR, DRUMS_CHILL, 42, 'chiptune') +const track27 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001, 'chiptune') +const track28 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313, 'chiptune') +const track29 = genTrack('Cyber Punk', 250, 117, SCALE_MINOR, DRUMS_FRANTIC, 99, 'chiptune') -// Track 10: "Metal Mayhem" — E phrygian, thrash, 270bpm -const track10 = genTrack('Metal Mayhem', 270, 82, SCALE_PHRYGIAN, DRUMS_FRANTIC, 666) +// === JAZZ tracks — walking bass, bebop lines, 7th chords, swing drums === +const track30 = genTrack('Jazz Fusion', 175, 156, SCALE_DORIAN, DRUMS_SWING, 333, 'jazz') +const track31 = genTrack('Shuffle Beatdown', 190, 196, SCALE_BLUES, DRUMS_SHUFFLE, 1955, 'jazz') +const track32 = genTrack('Smooth Operator', 165, 175, SCALE_DORIAN, DRUMS_SWING, 1961, 'jazz') -// Track 11: "Tropical Storm" — C mixolydian, upbeat, 185bpm -const track11 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808) +// === ELECTRONIC tracks — pulsing bass, filter sweeps, relentless arps === +const track33 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984, 'electronic') +const track34 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174, 'electronic') +const track35 = genTrack('Neon Overload', 230, 131, SCALE_MINOR, DRUMS_FRANTIC, 2077, 'electronic') +const track36 = genTrack('Breakbeat Fury', 240, 117, SCALE_MINOR, DRUMS_BREAK, 1997, 'electronic') -// Track 12: "Haunted Circus" — Bb harmonic minor, creepy, 200bpm -const track12 = genTrack('Haunted Circus', 200, 117, SCALE_HARMMINOR, DRUMS_MARCH, 1313) +// === LATIN tracks — tresillo bass, salsa piano, clave rhythms === +const track37 = genTrack('Latin Knockout', 200, 131, SCALE_HARMMINOR, DRUMS_LATIN, 1959, 'latin') +const track38 = genTrack('Acid Meltdown', 235, 110, SCALE_ARABIC, DRUMS_LATIN, 303, 'latin') -// Track 13: "Space Opera" — Ab major, majestic, 165bpm -const track13 = genTrack('Space Opera', 165, 208, SCALE_MAJOR, DRUMS_HALFTIME, 2001) +// === REGGAE tracks — one-drop bass, skank chops === +const track39 = genTrack('Dub Smash', 170, 196, SCALE_MINOR, DRUMS_REGGAE, 1973, 'reggae') +const track40 = genTrack('Reggae Rumble', 195, 156, SCALE_MAJOR, DRUMS_REGGAE, 1978, 'reggae') -// Track 14: "Funk Machine" — D mixolydian, groovy, 210bpm -const track14 = genTrack('Funk Machine', 210, 147, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 420) +// === GENRE BLENDS — unique crossovers === +const track41 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SHUFFLE, 1865, 'rock') +const track42 = genTrack('Tropical Storm', 185, 131, SCALE_MIXOLYDIAN, DRUMS_GROOVE, 808, 'funk') +const track43 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977, 'funk') -// Track 15: "Viking Raid" — A minor, epic march, 225bpm -const track15 = genTrack('Viking Raid', 225, 110, SCALE_MINOR, DRUMS_MARCH, 793) - -// Track 16: "Synthwave Dream" — F minor, dreamy, 170bpm -const track16 = genTrack('Synthwave Dream', 170, 175, SCALE_MINOR, DRUMS_HALFTIME, 1984) - -// Track 17: "Drum & Bass" — G minor, frantic, 255bpm -const track17 = genTrack('Drum & Bass', 255, 196, SCALE_MINOR, DRUMS_DNB, 174) - -// Track 18: "Western Duel" — E blues, twangy, 190bpm -const track18 = genTrack('Western Duel', 190, 165, SCALE_BLUES, DRUMS_SWING, 1865) - -// Track 19: "Samurai Storm" — B japanese, intense, 245bpm -const track19 = genTrack('Samurai Storm', 245, 123, SCALE_JAPANESE, DRUMS_HEAVY, 1603) - -// Track 20: "Disco Inferno" — Bb major, groovy, 215bpm -const track20 = genTrack('Disco Inferno', 215, 117, SCALE_MAJOR, DRUMS_GROOVE, 1977) - -const ALL_TRACKS = [track1, track2, track3, track4, track5, track6, track7, track8, track9, track10, track11, track12, track13, track14, track15, track16, track17, track18, track19, track20] +const ALL_TRACKS = [ + track1, track2, track3, track4, track5, track6, track7, track8, track9, track10, + track11, track12, track13, track14, track15, track16, track17, track18, track19, track20, + track21, track22, track23, track24, track25, track26, track27, track28, track29, track30, + track31, track32, track33, track34, track35, track36, track37, track38, track39, track40, + track41, track42, track43, +] let activeTrack: MusicTrack = track1 let barIndex = 0 @@ -1425,22 +1704,25 @@ function playMusicBar() { const off = bar * STEPS const step = beat / 2 - // Switch tracks at bar boundaries based on intensity - // Categorize tracks by energy level - const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185) // Chill Lounge, Space Opera, Synthwave Dream, Jazz Fusion, Tropical Storm - const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230) // Retro Arcade, Western Duel, Haunted Circus, Funk Machine, Disco Inferno, Neon Fury, Viking Raid - const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230) // Dark Circuit, Pixel Blitz, Skull Crusher, Cyber Punk, Boss Battle, Metal Mayhem, Samurai Storm, Drum & Bass - if (barIndex > 0 && bar === 0) { - if (currentIntensity > 0.7 && activeTrack.bpm < 230) { - activeTrack = intenseTracks[Math.floor(Math.random() * intenseTracks.length)] - } else if (currentIntensity < 0.3 && activeTrack.bpm >= 200) { - activeTrack = chillTracks[Math.floor(Math.random() * chillTracks.length)] - } else if (currentIntensity >= 0.3 && currentIntensity <= 0.7 && Math.random() < 0.3) { - activeTrack = midTracks[Math.floor(Math.random() * midTracks.length)] - } else if (Math.random() < 0.2) { - // Random switch for variety - const others = ALL_TRACKS.filter(t => t !== activeTrack) - activeTrack = others[Math.floor(Math.random() * others.length)] + // Switch tracks aggressively — every 2-4 bars, always to a DIFFERENT track + const chillTracks = ALL_TRACKS.filter(t => t.bpm < 185) + const midTracks = ALL_TRACKS.filter(t => t.bpm >= 185 && t.bpm < 230) + const intenseTracks = ALL_TRACKS.filter(t => t.bpm >= 230) + const switchEvery = currentIntensity > 0.7 ? 2 : currentIntensity > 0.4 ? 3 : 4 + if (barIndex > 0 && barIndex % switchEvery === 0) { + const pickFrom = (pool: MusicTrack[]) => { + const others = pool.filter(t => t !== activeTrack) + return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : pool[0] + } + if (currentIntensity > 0.7) { + activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS) + } else if (currentIntensity < 0.3) { + activeTrack = pickFrom(chillTracks.length > 0 ? chillTracks : ALL_TRACKS) + } else if (Math.random() < 0.5) { + activeTrack = pickFrom(midTracks.length > 0 ? midTracks : ALL_TRACKS) + } else { + // Full random for maximum variety + activeTrack = pickFrom(ALL_TRACKS) } } diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index ffa4cbb..0838f8e 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -281,14 +281,14 @@ const tierClass = (t: number) => `tier-${t}`