106 lines
3.0 KiB
TypeScript
106 lines
3.0 KiB
TypeScript
|
|
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;
|
||
|
|
}
|