cosmo/frontend/src/components/InterstellarTicker.tsx

106 lines
3.0 KiB
TypeScript
Raw Normal View History

2025-11-29 16:58:58 +00:00
import { useEffect, useRef, useCallback } from 'react';
interface InterstellarTickerProps {
isPlaying: boolean;
}
export function InterstellarTicker({ isPlaying }: InterstellarTickerProps) {
const audioContextRef = useRef<AudioContext | null>(null);
const timerRef = useRef<number | null>(null);
const nextNoteTimeRef = useRef<number>(0);
// 初始化 AudioContext (必须在用户交互后触发)
const initAudio = useCallback(() => {
if (!audioContextRef.current) {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
audioContextRef.current = new AudioContextClass();
}
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume();
}
}, []);
// 播放单次滴答声
const playTick = useCallback((time: number) => {
const ctx = audioContextRef.current;
if (!ctx) return;
// 1. 创建振荡器 (声音源)
const osc = ctx.createOscillator();
const gain = ctx.createGain();
// 2. 声音设计 - 模仿星际穿越的木鱼/秒表声
// 使用正弦波,频率较高
osc.type = 'sine';
osc.frequency.setValueAtTime(880, time); // A5 音高,清脆
// 3. 包络 (Envolope) - 极短的冲击感
// 0s: 静音
gain.gain.setValueAtTime(0, time);
// 0.005s: 快速达到峰值 (Attack)
gain.gain.linearRampToValueAtTime(0.8, time + 0.005);
// 0.05s: 快速衰减 (Decay)
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
// 4. 连接并播放
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(time);
osc.stop(time + 0.1);
}, []);
// 调度器 (Lookahead Scheduler)
const scheduler = useCallback(() => {
const ctx = audioContextRef.current;
if (!ctx) return;
// 预读 0.1 秒
const lookahead = 0.1;
const interval = 1.2; // 1.2 秒间隔
// 如果下一个音符的时间在当前时间 + 预读范围内,则调度它
while (nextNoteTimeRef.current < ctx.currentTime + lookahead) {
playTick(nextNoteTimeRef.current);
nextNoteTimeRef.current += interval;
}
timerRef.current = window.setTimeout(scheduler, 25);
}, [playTick]);
// 监听 isPlaying 变化
useEffect(() => {
if (isPlaying) {
// 开启声音
initAudio();
// 重置调度时间
if (audioContextRef.current) {
// 稍微延迟一点开始,避免切歌时的爆音
nextNoteTimeRef.current = audioContextRef.current.currentTime + 0.1;
scheduler();
}
} else {
// 关闭声音
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}
}, [isPlaying, initAudio, scheduler]);
// 组件卸载时清理
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
if (audioContextRef.current) {
audioContextRef.current.close();
}
};
}, []);
// 这是一个纯逻辑组件,不渲染任何 UI
return null;
}