| 1 | /** Token-driven generative ambient music; opt-in via the "generativeMusicPreset" localStorage key. */ |
| 2 | |
| 3 | export type GenerativePreset = "off" | "classic" | "ethereal" | "digital" | "retro"; |
| 4 | |
| 5 | type NonEmptyPreset = "classic" | "ethereal" | "digital" | "retro"; |
| 6 | |
| 7 | interface PresetConfig { |
| 8 | oscillatorType: OscillatorType; |
| 9 | attack: number; // 秒 |
| 10 | decay: number; // 秒 |
| 11 | sustain: number; // 0-1 电平 |
| 12 | release: number; // 秒 |
| 13 | filterFreq: number; // Hz |
| 14 | reverbDecay: number; // 秒(脉冲响应长度) |
| 15 | reverbWet: number; // 0-1 混合比 |
| 16 | masterVolume: number; // 0-1 |
| 17 | } |
| 18 | |
| 19 | const PRESETS: Record<NonEmptyPreset, PresetConfig> = { |
| 20 | classic: { |
| 21 | oscillatorType: "triangle", |
| 22 | attack: 0.01, |
| 23 | decay: 0.2, |
| 24 | sustain: 0.2, |
| 25 | release: 0.3, |
| 26 | filterFreq: 2000, |
| 27 | reverbDecay: 0.3, |
| 28 | reverbWet: 0.2, |
| 29 | masterVolume: 0.08, |
| 30 | }, |
| 31 | ethereal: { |
| 32 | oscillatorType: "sine", |
| 33 | attack: 0.1, |
| 34 | decay: 0.4, |
| 35 | sustain: 0.4, |
| 36 | release: 0.8, |
| 37 | filterFreq: 3000, |
| 38 | reverbDecay: 0.8, |
| 39 | reverbWet: 0.35, |
| 40 | masterVolume: 0.07, |
| 41 | }, |
| 42 | digital: { |
| 43 | oscillatorType: "square", |
| 44 | attack: 0.005, |
| 45 | decay: 0.1, |
| 46 | sustain: 0.1, |
| 47 | release: 0.1, |
| 48 | filterFreq: 5000, |
| 49 | reverbDecay: 0.05, |
| 50 | reverbWet: 0.05, |
| 51 | masterVolume: 0.06, |
| 52 | }, |
| 53 | retro: { |
| 54 | oscillatorType: "sawtooth", |
| 55 | attack: 0.02, |
| 56 | decay: 0.3, |
| 57 | sustain: 0.3, |
| 58 | release: 0.4, |
| 59 | filterFreq: 1500, |
| 60 | reverbDecay: 0.35, |
| 61 | reverbWet: 0.25, |
| 62 | masterVolume: 0.08, |
| 63 | }, |
| 64 | }; |
| 65 | |
| 66 | // C 大调五声音阶,无半音 → 任意顺序都和谐 |
| 67 | const SCALE_FREQS = [261.63, 293.66, 349.23, 392.0, 440.0, 523.25, 587.33]; // C4–D5 |
| 68 | const OCTAVE_FREQS = [130.81, 146.83, 174.61, 196.0, 220.0, 261.63, 293.66]; // C3–D3 |
| 69 | |
| 70 | const PRESET_KEY = "generativeMusicPreset"; |
| 71 | |
| 72 | function readPresetPref(): GenerativePreset { |
| 73 | if (typeof localStorage === "undefined") return "off"; |
| 74 | const val = localStorage.getItem(PRESET_KEY); |
| 75 | if (val === "off" || val === "classic" || val === "ethereal" || val === "digital" || val === "retro") return val; |
| 76 | return "off"; |
| 77 | } |
| 78 | |
| 79 | function writePresetPref(pref: GenerativePreset): void { |
| 80 | if (typeof localStorage !== "undefined") { |
| 81 | localStorage.setItem(PRESET_KEY, pref); |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | export function getGenerativePreset(): GenerativePreset { |
| 86 | return readPresetPref(); |
| 87 | } |
| 88 | |
| 89 | export function setGenerativePreset(pref: GenerativePreset): void { |
| 90 | writePresetPref(pref); |
| 91 | } |
| 92 | |
| 93 | export function isGenerativeMusicEnabled(): boolean { |
| 94 | return readPresetPref() !== "off"; |
| 95 | } |
| 96 | |
| 97 | class GenerativeMusicEngine { |
| 98 | private ctx: AudioContext | null = null; |
| 99 | private masterGain: GainNode | null = null; |
| 100 | private filterNode: BiquadFilterNode | null = null; |
| 101 | private reverbNode: ConvolverNode | null = null; |
| 102 | private reverbGain: GainNode | null = null; // wet |
| 103 | private preset: NonEmptyPreset = "ethereal"; |
| 104 | private running = false; |
| 105 | private lastNoteTime = 0; |
| 106 | private tokenToggle = 0; |
| 107 | |
| 108 | start(preset?: NonEmptyPreset): void { |
| 109 | if (this.running) return; |
| 110 | const p = preset ?? readPresetPref(); |
| 111 | if (p === "off") return; |
| 112 | this.preset = p; |
| 113 | this.running = true; |
| 114 | |
| 115 | try { |
| 116 | this.ctx = new AudioContext(); |
| 117 | this.buildSignalChain(); |
| 118 | } catch (e) { |
| 119 | console.warn("generative-music: failed to create AudioContext", e); |
| 120 | this.running = false; |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | stop(): void { |
| 125 | if (!this.running) return; |
| 126 | this.running = false; |
| 127 | |
| 128 | if (this.masterGain && this.ctx) { |
| 129 | try { |
| 130 | const now = this.ctx.currentTime; |
| 131 | this.masterGain.gain.cancelScheduledValues(now); |
| 132 | this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now); |
| 133 | this.masterGain.gain.linearRampToValueAtTime(0, now + 0.3); |
| 134 | } catch (e) { |
| 135 | console.warn("generative-music: gain ramp failed", e); |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | const ctx = this.ctx; |
| 140 | setTimeout(() => { |
| 141 | ctx?.close(); |
| 142 | }, 500); |
| 143 | this.ctx = null; |
| 144 | this.masterGain = null; |
| 145 | this.filterNode = null; |
| 146 | this.reverbNode = null; |
| 147 | this.reverbGain = null; |
| 148 | } |
| 149 | |
| 150 | setPreset(preset: NonEmptyPreset): void { |
| 151 | this.preset = preset; |
| 152 | writePresetPref(preset); |
| 153 | if (this.ctx && this.masterGain) { |
| 154 | this.buildSignalChain(); |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | get isRunning(): boolean { |
| 159 | return this.running; |
| 160 | } |
| 161 | |
| 162 | /** 双重节流:每 4 个 token + 最小 100ms 间隔,适配 DeepSeek 高频输出。 */ |
| 163 | playTokenNote(): void { |
| 164 | if (!this.running || !this.ctx) return; |
| 165 | |
| 166 | this.tokenToggle++; |
| 167 | if (this.tokenToggle % 4 !== 0) return; |
| 168 | |
| 169 | const now = performance.now(); |
| 170 | if (now - this.lastNoteTime < 100) return; |
| 171 | this.lastNoteTime = now; |
| 172 | |
| 173 | const time = this.ctx.currentTime; |
| 174 | |
| 175 | const useLow = Math.random() < 0.15; |
| 176 | const freqs = useLow ? OCTAVE_FREQS : SCALE_FREQS; |
| 177 | const freq = freqs[Math.floor(Math.random() * freqs.length)]; |
| 178 | |
| 179 | const detune = 1 + (Math.random() - 0.5) * 0.1; |
| 180 | this.playNote(time, freq * detune); |
| 181 | } |
| 182 | |
| 183 | private buildSignalChain(): void { |
| 184 | if (!this.ctx) return; |
| 185 | const cfg = PRESETS[this.preset]; |
| 186 | |
| 187 | if (this.masterGain) this.masterGain.disconnect(); |
| 188 | this.masterGain = this.ctx.createGain(); |
| 189 | this.masterGain.gain.value = cfg.masterVolume; |
| 190 | this.masterGain.connect(this.ctx.destination); |
| 191 | |
| 192 | if (this.filterNode) this.filterNode.disconnect(); |
| 193 | this.filterNode = this.ctx.createBiquadFilter(); |
| 194 | this.filterNode.type = "lowpass"; |
| 195 | this.filterNode.frequency.value = cfg.filterFreq; |
| 196 | this.filterNode.Q.value = 1; |
| 197 | this.filterNode.connect(this.masterGain); |
| 198 | |
| 199 | if (this.reverbNode) this.reverbNode.disconnect(); |
| 200 | if (this.reverbGain) this.reverbGain.disconnect(); |
| 201 | |
| 202 | this.reverbNode = this.ctx.createConvolver(); |
| 203 | this.reverbNode.buffer = this.createReverbImpulse(this.ctx, cfg.reverbDecay); |
| 204 | this.reverbGain = this.ctx.createGain(); |
| 205 | this.reverbGain.gain.value = cfg.reverbWet; |
| 206 | this.filterNode.connect(this.reverbNode); |
| 207 | this.reverbNode.connect(this.reverbGain); |
| 208 | this.reverbGain.connect(this.masterGain); |
| 209 | } |
| 210 | |
| 211 | private createReverbImpulse(ctx: AudioContext, duration: number): AudioBuffer { |
| 212 | const sampleRate = ctx.sampleRate; |
| 213 | const length = Math.max(Math.round(sampleRate * duration), 1); |
| 214 | const buffer = ctx.createBuffer(2, length, sampleRate); |
| 215 | for (let ch = 0; ch < 2; ch++) { |
| 216 | const data = buffer.getChannelData(ch); |
| 217 | for (let i = 0; i < length; i++) { |
| 218 | const t = i / length; |
| 219 | data[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, 1.5) * (1 + Math.sin(t * Math.PI * 3) * 0.3); |
| 220 | } |
| 221 | } |
| 222 | return buffer; |
| 223 | } |
| 224 | |
| 225 | private playNote(time: number, freq: number): void { |
| 226 | if (!this.ctx) return; |
| 227 | const cfg = PRESETS[this.preset]; |
| 228 | const now = this.ctx.currentTime; |
| 229 | const t = Math.max(time, now + 0.005); |
| 230 | |
| 231 | const osc = this.ctx.createOscillator(); |
| 232 | osc.type = cfg.oscillatorType; |
| 233 | osc.frequency.setValueAtTime(freq, t); |
| 234 | |
| 235 | const envGain = this.ctx.createGain(); |
| 236 | const env = envGain.gain; |
| 237 | const noteEnd = t + cfg.attack + cfg.decay + cfg.release + 0.01; |
| 238 | env.setValueAtTime(0, t); |
| 239 | env.linearRampToValueAtTime(1, t + cfg.attack); |
| 240 | env.linearRampToValueAtTime(cfg.sustain, t + cfg.attack + cfg.decay); |
| 241 | env.setValueAtTime(cfg.sustain, t + cfg.attack + cfg.decay + 0.01); |
| 242 | env.linearRampToValueAtTime(0.001, t + cfg.attack + cfg.decay + 0.01 + cfg.release); |
| 243 | |
| 244 | osc.connect(envGain); |
| 245 | if (this.filterNode) { |
| 246 | envGain.connect(this.filterNode); |
| 247 | } else { |
| 248 | envGain.connect(this.ctx.destination); |
| 249 | } |
| 250 | |
| 251 | osc.start(t); |
| 252 | osc.stop(noteEnd); |
| 253 | } |
| 254 | |
| 255 | playPreview(preset: NonEmptyPreset): void { |
| 256 | const ctx = new AudioContext(); |
| 257 | const cfg = PRESETS[preset]; |
| 258 | |
| 259 | const filter = ctx.createBiquadFilter(); |
| 260 | filter.type = "lowpass"; |
| 261 | filter.frequency.value = cfg.filterFreq; |
| 262 | |
| 263 | const reverb = ctx.createConvolver(); |
| 264 | reverb.buffer = this.createReverbImpulse(ctx, cfg.reverbDecay); |
| 265 | const reverbGain = ctx.createGain(); |
| 266 | reverbGain.gain.value = cfg.reverbWet; |
| 267 | |
| 268 | const master = ctx.createGain(); |
| 269 | master.gain.value = cfg.masterVolume * 2; |
| 270 | master.connect(ctx.destination); |
| 271 | |
| 272 | filter.connect(master); |
| 273 | filter.connect(reverb); |
| 274 | reverb.connect(reverbGain); |
| 275 | reverbGain.connect(master); |
| 276 | |
| 277 | const notes = [SCALE_FREQS[0], SCALE_FREQS[2], SCALE_FREQS[4], SCALE_FREQS[6]]; |
| 278 | notes.forEach((freq, i) => { |
| 279 | const t = ctx.currentTime + i * 0.05; |
| 280 | |
| 281 | const osc = ctx.createOscillator(); |
| 282 | osc.type = cfg.oscillatorType; |
| 283 | osc.frequency.setValueAtTime(freq, t); |
| 284 | |
| 285 | const env = ctx.createGain(); |
| 286 | env.gain.setValueAtTime(0, t); |
| 287 | env.gain.linearRampToValueAtTime(1, t + cfg.attack); |
| 288 | env.gain.linearRampToValueAtTime(cfg.sustain, t + cfg.attack + cfg.decay); |
| 289 | env.gain.setValueAtTime(cfg.sustain, t + 0.05); |
| 290 | env.gain.linearRampToValueAtTime(0.001, t + cfg.attack + cfg.decay + cfg.release); |
| 291 | |
| 292 | osc.connect(env); |
| 293 | env.connect(filter); |
| 294 | osc.start(t); |
| 295 | osc.stop(t + cfg.attack + cfg.decay + cfg.release + 0.05); |
| 296 | }); |
| 297 | |
| 298 | setTimeout(() => ctx.close(), 3000); |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | export const generativeMusic = new GenerativeMusicEngine(); |
| 303 |