200 lines
5.2 KiB
TypeScript
200 lines
5.2 KiB
TypeScript
/**
|
|||
|
|
* Login screen audio: intro loop (MP3) + transition sounds.
|
||
|
|
*/
|
||
|
|
|
||
|
|
let audioContext: AudioContext | null = null
|
||
|
|
let introAudio: HTMLAudioElement | null = null
|
||
|
|
let introGain: GainNode | null = null
|
||
|
|
|
||
|
|
function getContext(): AudioContext | null {
|
||
|
|
if (audioContext) return audioContext
|
||
|
|
try {
|
||
|
|
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||
|
|
return audioContext
|
||
|
|
} catch {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function playTone(
|
||
|
|
ctx: AudioContext,
|
||
|
|
freq: number,
|
||
|
|
duration: number,
|
||
|
|
gain: number,
|
||
|
|
type: OscillatorType = 'sine',
|
||
|
|
startOffset = 0,
|
||
|
|
dest: AudioNode = ctx.destination
|
||
|
|
) {
|
||
|
|
const osc = ctx.createOscillator()
|
||
|
|
const g = ctx.createGain()
|
||
|
|
osc.connect(g)
|
||
|
|
g.connect(dest)
|
||
|
|
g.gain.setValueAtTime(0, ctx.currentTime)
|
||
|
|
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
|
||
|
|
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
||
|
|
osc.frequency.value = freq
|
||
|
|
osc.type = type
|
||
|
|
osc.start(ctx.currentTime + startOffset)
|
||
|
|
osc.stop(ctx.currentTime + startOffset + duration)
|
||
|
|
}
|
||
|
|
|
||
|
|
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
|
||
|
|
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
||
|
|
|
||
|
|
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in */
|
||
|
|
export function playLoopStart() {
|
||
|
|
try {
|
||
|
|
const audio = new Audio(LOOP_START_URL)
|
||
|
|
audio.volume = 0.5
|
||
|
|
audio.play().catch(() => {})
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Resume audio context (call on first user interaction to unlock autoplay) */
|
||
|
|
export function resumeAudioContext() {
|
||
|
|
const ctx = getContext()
|
||
|
|
if (ctx?.state === 'suspended') {
|
||
|
|
ctx.resume().catch(() => {})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Start intro loop - Cosmic Updrift (free royalty) */
|
||
|
|
export function startSynthwave() {
|
||
|
|
const ctxOrNull = getContext()
|
||
|
|
if (!ctxOrNull) return
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (ctxOrNull.state === 'suspended') ctxOrNull.resume()
|
||
|
|
} catch {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
stopSynthwave()
|
||
|
|
|
||
|
|
const audio = new Audio(INTRO_AUDIO_URL)
|
||
|
|
audio.loop = true
|
||
|
|
|
||
|
|
const ctx = ctxOrNull
|
||
|
|
const source = ctx.createMediaElementSource(audio)
|
||
|
|
const gainNode = ctx.createGain()
|
||
|
|
gainNode.gain.value = 0.25
|
||
|
|
source.connect(gainNode)
|
||
|
|
gainNode.connect(ctx.destination)
|
||
|
|
introGain = gainNode
|
||
|
|
introAudio = audio
|
||
|
|
|
||
|
|
audio.play().catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Stop intro loop (call on login success) */
|
||
|
|
export function stopSynthwave() {
|
||
|
|
if (introAudio) {
|
||
|
|
if (introGain && audioContext) {
|
||
|
|
const t = audioContext.currentTime
|
||
|
|
introGain.gain.setValueAtTime(1, t)
|
||
|
|
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
|
||
|
|
}
|
||
|
|
setTimeout(() => {
|
||
|
|
introAudio?.pause()
|
||
|
|
introAudio = null
|
||
|
|
introGain = null
|
||
|
|
}, 220)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whoosh transition on successful login */
|
||
|
|
export function playLoginSuccessWhoosh() {
|
||
|
|
const woosh = new Audio('/assets/audio/woosh.mp3')
|
||
|
|
woosh.volume = 0.5
|
||
|
|
woosh.play().catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Typing sound - plays once when welcome typing starts (typing.mp3) */
|
||
|
|
export function playTypingSound() {
|
||
|
|
const audio = new Audio('/assets/audio/typing.mp3')
|
||
|
|
audio.volume = 0.6
|
||
|
|
audio.play().catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Intro typing - ONE sound per sentence: play when sentence starts, stop when it ends (intro-typing.mp3 from archy assets) */
|
||
|
|
let introTypingAudio: HTMLAudioElement | null = null
|
||
|
|
const INTRO_TYPING_URL = '/assets/audio/intro-typing.mp3'
|
||
|
|
|
||
|
|
export function playIntroTyping() {
|
||
|
|
stopIntroTyping()
|
||
|
|
introTypingAudio = new Audio(INTRO_TYPING_URL)
|
||
|
|
introTypingAudio.volume = 0.5
|
||
|
|
introTypingAudio.loop = true
|
||
|
|
introTypingAudio.play().catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
export function stopIntroTyping() {
|
||
|
|
if (introTypingAudio) {
|
||
|
|
introTypingAudio.pause()
|
||
|
|
introTypingAudio.currentTime = 0
|
||
|
|
introTypingAudio = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Typing tick - for dashboard welcome typing (typing.mp3) */
|
||
|
|
let typingTickPool: HTMLAudioElement[] = []
|
||
|
|
const TYPING_TICK_POOL_SIZE = 5
|
||
|
|
|
||
|
|
function getTypingTick(): HTMLAudioElement {
|
||
|
|
if (typingTickPool.length === 0) {
|
||
|
|
for (let i = 0; i < TYPING_TICK_POOL_SIZE; i++) {
|
||
|
|
const a = new Audio('/assets/audio/typing.mp3')
|
||
|
|
a.volume = 0.4
|
||
|
|
typingTickPool.push(a)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const a = typingTickPool.shift()!
|
||
|
|
typingTickPool.push(a)
|
||
|
|
return a
|
||
|
|
}
|
||
|
|
|
||
|
|
export function playTypingTick() {
|
||
|
|
const a = getTypingTick()
|
||
|
|
a.currentTime = 0
|
||
|
|
a.play().catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||
|
|
export function playDashboardLoadOomph() {
|
||
|
|
const ctx = getContext()
|
||
|
|
if (!ctx) return
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (ctx.state === 'suspended') ctx.resume()
|
||
|
|
} catch {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const t = ctx.currentTime
|
||
|
|
|
||
|
|
// Soft layered thud - sine only, smooth attack/decay
|
||
|
|
const layers = [
|
||
|
|
{ freq: 55, dur: 0.35, gain: 0.4, attack: 0.02 },
|
||
|
|
{ freq: 82, dur: 0.28, gain: 0.25, attack: 0.03 },
|
||
|
|
{ freq: 110, dur: 0.22, gain: 0.15, attack: 0.04 },
|
||
|
|
{ freq: 165, dur: 0.18, gain: 0.1, attack: 0.05 },
|
||
|
|
]
|
||
|
|
|
||
|
|
for (let i = 0; i < layers.length; i++) {
|
||
|
|
const L = layers[i]!
|
||
|
|
const osc = ctx.createOscillator()
|
||
|
|
const g = ctx.createGain()
|
||
|
|
osc.type = 'sine'
|
||
|
|
osc.frequency.value = L.freq
|
||
|
|
g.gain.setValueAtTime(0, t)
|
||
|
|
g.gain.linearRampToValueAtTime(L.gain, t + L.attack)
|
||
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + L.dur)
|
||
|
|
osc.connect(g)
|
||
|
|
g.connect(ctx.destination)
|
||
|
|
osc.start(t + i * 0.01)
|
||
|
|
osc.stop(t + L.dur)
|
||
|
|
}
|
||
|
|
}
|