新增: 音频可视化壁纸主题(WASAPI+FFT+Canvas)
This commit is contained in:
412
audio.go
Normal file
412
audio.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"math/cmplx"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// FFT: 纯 Go radix-2 Cooley-Tukey (1024 点)
|
||||
// ============================================================
|
||||
|
||||
const fftSize = 1024
|
||||
const targetBins = 128
|
||||
|
||||
func fft(x []complex128) {
|
||||
n := len(x)
|
||||
if n <= 1 {
|
||||
return
|
||||
}
|
||||
for i, j := 1, 0; i < n; i++ {
|
||||
bit := n >> 1
|
||||
for j&bit != 0 {
|
||||
j ^= bit
|
||||
bit >>= 1
|
||||
}
|
||||
j ^= bit
|
||||
if i < j {
|
||||
x[i], x[j] = x[j], x[i]
|
||||
}
|
||||
}
|
||||
for length := 2; length <= n; length <<= 1 {
|
||||
half := length >> 1
|
||||
angle := -2 * math.Pi / float64(length)
|
||||
wn := complex(math.Cos(angle), math.Sin(angle))
|
||||
for i := 0; i < n; i += length {
|
||||
w := complex(1, 0)
|
||||
for j := 0; j < half; j++ {
|
||||
u := x[i+j]
|
||||
v := x[i+j+half] * w
|
||||
x[i+j] = u + v
|
||||
x[i+j+half] = u - v
|
||||
w *= wn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fftMagnitude(input []float32) []float64 {
|
||||
n := len(input)
|
||||
if n > fftSize {
|
||||
n = fftSize
|
||||
input = input[:fftSize]
|
||||
}
|
||||
x := make([]complex128, fftSize)
|
||||
for i := 0; i < n; i++ {
|
||||
w := 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1)))
|
||||
x[i] = complex(float64(input[i])*w, 0)
|
||||
}
|
||||
fft(x)
|
||||
mag := make([]float64, fftSize/2)
|
||||
for i := range mag {
|
||||
mag[i] = cmplx.Abs(x[i]) / float64(fftSize)
|
||||
}
|
||||
return mag
|
||||
}
|
||||
|
||||
func downsampleBins(magnitudes []float64, target int) []float64 {
|
||||
srcLen := len(magnitudes)
|
||||
if srcLen == 0 || target == 0 {
|
||||
return make([]float64, target)
|
||||
}
|
||||
bins := make([]float64, target)
|
||||
for i := 0; i < target; i++ {
|
||||
frac := float64(i) / float64(target)
|
||||
start := int(math.Pow(float64(srcLen), frac))
|
||||
fracNext := float64(i+1) / float64(target)
|
||||
end := int(math.Pow(float64(srcLen), fracNext))
|
||||
if end <= start {
|
||||
end = start + 1
|
||||
}
|
||||
if end > srcLen {
|
||||
end = srcLen
|
||||
}
|
||||
sum := 0.0
|
||||
for j := start; j < end; j++ {
|
||||
sum += magnitudes[j]
|
||||
}
|
||||
bins[i] = sum / float64(end-start)
|
||||
}
|
||||
return bins
|
||||
}
|
||||
|
||||
func smoothBins(prev, curr []float64, factor float64) []float64 {
|
||||
n := len(curr)
|
||||
if len(prev) != n {
|
||||
return curr
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = prev[i]*factor + curr[i]*(1-factor)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COM vtable 调用辅助
|
||||
// ============================================================
|
||||
|
||||
func comCall(obj uintptr, vtableIdx uintptr, args ...uintptr) uintptr {
|
||||
if obj == 0 {
|
||||
return 0x80004003 // E_POINTER
|
||||
}
|
||||
vtable := *(*uintptr)(unsafe.Pointer(obj))
|
||||
method := *(*uintptr)(unsafe.Pointer(vtable + vtableIdx*unsafe.Sizeof(uintptr(0))))
|
||||
// 构建参数数组 (this + args),最多支持 12 参数
|
||||
a := make([]uintptr, 12)
|
||||
a[0] = obj
|
||||
for i, arg := range args {
|
||||
a[i+1] = arg
|
||||
}
|
||||
n := uintptr(len(args) + 1)
|
||||
switch {
|
||||
case n <= 3:
|
||||
r, _, _ := syscall.Syscall(method, n, a[0], a[1], a[2])
|
||||
return r
|
||||
case n <= 6:
|
||||
r, _, _ := syscall.Syscall6(method, n, a[0], a[1], a[2], a[3], a[4], a[5])
|
||||
return r
|
||||
case n <= 9:
|
||||
r, _, _ := syscall.Syscall9(method, n, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8])
|
||||
return r
|
||||
case n <= 12:
|
||||
r, _, _ := syscall.Syscall12(method, n, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11])
|
||||
return r
|
||||
default:
|
||||
return 0x80070057 // E_INVALIDARG
|
||||
}
|
||||
}
|
||||
|
||||
// COM GUID 常量
|
||||
var (
|
||||
clsidMMDeviceEnumerator = windows.GUID{Data1: 0xBCDE0395, Data2: 0xE52F, Data3: 0x467C, Data4: [8]byte{0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E}}
|
||||
iidIMMDeviceEnumerator = windows.GUID{Data1: 0xA95664D2, Data2: 0x9614, Data3: 0x4F35, Data4: [8]byte{0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6}}
|
||||
iidIAudioClient = windows.GUID{Data1: 0x1CB9AD4C, Data2: 0xDBFA, Data3: 0x4C32, Data4: [8]byte{0xB1, 0x78, 0xC2, 0xF5, 0x68, 0xA7, 0x03, 0xB2}}
|
||||
iidIAudioCaptureClient = windows.GUID{Data1: 0xC8ADBD64, Data2: 0xE71E, Data3: 0x48A0, Data4: [8]byte{0xA4, 0xDE, 0x18, 0x5C, 0x39, 0x5C, 0xD3, 0x17}}
|
||||
)
|
||||
|
||||
// IMMDeviceEnumerator vtable (IUnknown 3 + 自身方法)
|
||||
const (
|
||||
enumeratorGetDefaultAudioEndpoint = 4 // 第 4 个方法 (0-based from vtable start)
|
||||
)
|
||||
|
||||
// IMMDevice vtable
|
||||
const (
|
||||
deviceActivate = 3 // 第 3 个方法
|
||||
)
|
||||
|
||||
// IAudioClient vtable
|
||||
const (
|
||||
audioClientInitialize = 3
|
||||
audioClientGetBufferSize = 4
|
||||
audioClientGetMixFormat = 8
|
||||
audioClientStart = 10
|
||||
audioClientStop = 11
|
||||
audioClientGetService = 14
|
||||
)
|
||||
|
||||
// IAudioCaptureClient vtable
|
||||
const (
|
||||
captureGetBuffer = 3
|
||||
captureReleaseBuffer = 4
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// WASAPI 捕获生命周期
|
||||
// ============================================================
|
||||
|
||||
var (
|
||||
audioMu sync.Mutex
|
||||
audioStop chan struct{}
|
||||
audioDone chan struct{}
|
||||
audioActive bool
|
||||
smoothed []float64
|
||||
)
|
||||
|
||||
func startAudioCapture() {
|
||||
audioMu.Lock()
|
||||
defer audioMu.Unlock()
|
||||
if audioActive {
|
||||
return
|
||||
}
|
||||
audioStop = make(chan struct{})
|
||||
audioDone = make(chan struct{})
|
||||
audioActive = true
|
||||
smoothed = nil
|
||||
go audioCaptureLoop()
|
||||
log.Println("音频捕获: 已启动")
|
||||
}
|
||||
|
||||
func stopAudioCapture() {
|
||||
audioMu.Lock()
|
||||
stop := audioStop
|
||||
audioMu.Unlock()
|
||||
if stop == nil {
|
||||
return
|
||||
}
|
||||
close(stop)
|
||||
if audioDone != nil {
|
||||
<-audioDone
|
||||
}
|
||||
audioMu.Lock()
|
||||
audioActive = false
|
||||
audioStop = nil
|
||||
audioDone = nil
|
||||
audioMu.Unlock()
|
||||
log.Println("音频捕获: 已停止")
|
||||
}
|
||||
|
||||
func audioActiveState() bool {
|
||||
audioMu.Lock()
|
||||
defer audioMu.Unlock()
|
||||
return audioActive
|
||||
}
|
||||
|
||||
func audioCaptureLoop() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
// COM 初始化
|
||||
ole32dll.NewProc("CoInitializeEx").Call(0, 0)
|
||||
defer ole32dll.NewProc("CoUninitialize").Call()
|
||||
|
||||
// CoCreateInstance -> IMMDeviceEnumerator
|
||||
var enumerator uintptr
|
||||
hr, _, _ := ole32dll.NewProc("CoCreateInstance").Call(
|
||||
uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)),
|
||||
0, 1, // CLSCTX_INPROC_SERVER
|
||||
uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)),
|
||||
uintptr(unsafe.Pointer(&enumerator)),
|
||||
)
|
||||
if hr != 0 || enumerator == 0 {
|
||||
log.Printf("音频捕获: 创建枚举器失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(enumerator)
|
||||
|
||||
// enumerator->GetDefaultAudioEndpoint(eRender=0, eConsole=0)
|
||||
var device uintptr
|
||||
hr = comCall(enumerator, enumeratorGetDefaultAudioEndpoint, 0, 0, uintptr(unsafe.Pointer(&device)))
|
||||
if hr != 0 || device == 0 {
|
||||
log.Printf("音频捕获: 获取默认音频设备失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(device)
|
||||
|
||||
// device->Activate(IID_IAudioClient, CLSCTX_INPROC=1, NULL, &audioClient)
|
||||
var audioClient uintptr
|
||||
hr = comCall(device, deviceActivate,
|
||||
uintptr(unsafe.Pointer(&iidIAudioClient)),
|
||||
1, 0,
|
||||
uintptr(unsafe.Pointer(&audioClient)),
|
||||
)
|
||||
if hr != 0 || audioClient == 0 {
|
||||
log.Printf("音频捕获: Activate(IAudioClient) 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(audioClient)
|
||||
|
||||
// audioClient->GetMixFormat(&pwfx)
|
||||
var pwfx uintptr
|
||||
hr = comCall(audioClient, audioClientGetMixFormat, uintptr(unsafe.Pointer(&pwfx)))
|
||||
if hr != 0 || pwfx == 0 {
|
||||
log.Printf("音频捕获: GetMixFormat 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer ole32dll.NewProc("CoTaskMemFree").Call(pwfx)
|
||||
|
||||
nChannels := *(*uint16)(unsafe.Pointer(pwfx + 2))
|
||||
nSamplesPerSec := *(*uint32)(unsafe.Pointer(pwfx + 4))
|
||||
log.Printf("音频捕获: 格式 ch=%d rate=%d", nChannels, nSamplesPerSec)
|
||||
|
||||
// audioClient->Initialize(SHARED, LOOPBACK, 1s buffer, 0, pwfx, NULL)
|
||||
hr = comCall(audioClient, audioClientInitialize,
|
||||
0, // AUDCLNT_SHAREMODE_SHARED
|
||||
0x00020000, // AUDCLNT_STREAMFLAGS_LOOPBACK
|
||||
10000000, // 1 second (100ns units)
|
||||
0,
|
||||
pwfx,
|
||||
0,
|
||||
)
|
||||
if hr != 0 {
|
||||
log.Printf("音频捕获: Initialize 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
|
||||
// audioClient->GetService(IID_IAudioCaptureClient, &captureClient)
|
||||
var captureClient uintptr
|
||||
hr = comCall(audioClient, audioClientGetService,
|
||||
uintptr(unsafe.Pointer(&iidIAudioCaptureClient)),
|
||||
uintptr(unsafe.Pointer(&captureClient)),
|
||||
)
|
||||
if hr != 0 || captureClient == 0 {
|
||||
log.Printf("音频捕获: GetService(IAudioCaptureClient) 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(captureClient)
|
||||
|
||||
// audioClient->Start()
|
||||
comCall(audioClient, audioClientStart)
|
||||
defer comCall(audioClient, audioClientStop)
|
||||
|
||||
// 环形缓冲区 (~100ms)
|
||||
ch := int(nChannels)
|
||||
bufSize := int(nSamplesPerSec)/10*ch + fftSize*ch
|
||||
ringBuf := make([]float32, bufSize)
|
||||
writePos := 0
|
||||
|
||||
cfg := loadConfig()
|
||||
smoothingFactor := cfg.AudioSmoothing
|
||||
if smoothingFactor <= 0 {
|
||||
smoothingFactor = 0.7
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(33 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-audioStop:
|
||||
close(audioDone)
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
// 读取 WASAPI 缓冲区
|
||||
for {
|
||||
var pData, nFrames, flags uintptr
|
||||
hr = comCall(captureClient, captureGetBuffer,
|
||||
uintptr(unsafe.Pointer(&pData)),
|
||||
uintptr(unsafe.Pointer(&nFrames)),
|
||||
uintptr(unsafe.Pointer(&flags)),
|
||||
0, 0,
|
||||
)
|
||||
if hr != 0 || nFrames == 0 {
|
||||
break
|
||||
}
|
||||
samples := unsafe.Slice((*float32)(unsafe.Pointer(pData)), nFrames*uintptr(ch))
|
||||
for _, s := range samples {
|
||||
ringBuf[writePos] = s
|
||||
writePos = (writePos + 1) % bufSize
|
||||
}
|
||||
comCall(captureClient, captureReleaseBuffer, uintptr(nFrames))
|
||||
}
|
||||
|
||||
// 取 fftSize 个单声道样本
|
||||
mono := make([]float32, fftSize)
|
||||
for i := 0; i < fftSize; i++ {
|
||||
pos := (writePos - fftSize*ch + i*ch + bufSize) % bufSize
|
||||
var sum float32
|
||||
for c := 0; c < ch; c++ {
|
||||
sum += ringBuf[(pos+c)%bufSize]
|
||||
}
|
||||
mono[i] = sum / float32(ch)
|
||||
}
|
||||
|
||||
mag := fftMagnitude(mono)
|
||||
bins := downsampleBins(mag, targetBins)
|
||||
|
||||
if smoothed != nil {
|
||||
bins = smoothBins(smoothed, bins, smoothingFactor)
|
||||
}
|
||||
smoothed = bins
|
||||
|
||||
// 归一化
|
||||
maxVal := 0.0
|
||||
for _, v := range bins {
|
||||
if v > maxVal {
|
||||
maxVal = v
|
||||
}
|
||||
}
|
||||
if maxVal > 0.001 {
|
||||
for i := range bins {
|
||||
bins[i] /= maxVal
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(bins)
|
||||
evalJS(fmt.Sprintf(`if(window.updateAudioDataFromGo) updateAudioDataFromGo(%s)`, string(data)))
|
||||
}
|
||||
}
|
||||
|
||||
func comRelease(obj uintptr) {
|
||||
if obj != 0 {
|
||||
comCall(obj, 2) // IUnknown::Release = vtable index 2
|
||||
}
|
||||
}
|
||||
19
config.go
19
config.go
@@ -33,6 +33,7 @@ const (
|
||||
ThemeParticle ThemeName = "particles"
|
||||
ThemeFractal ThemeName = "fractal"
|
||||
ThemeText ThemeName = "text"
|
||||
ThemeAudioViz ThemeName = "audio-viz"
|
||||
)
|
||||
|
||||
type SavedColor struct {
|
||||
@@ -74,6 +75,10 @@ type Config struct {
|
||||
UpdateURL string `json:"updateUrl,omitempty"`
|
||||
AutoCheckUpdate bool `json:"autoCheckUpdate"`
|
||||
SkipVersion string `json:"skipVersion,omitempty"`
|
||||
AudioVizStyle string `json:"audioVizStyle"`
|
||||
AudioSensitivity float64 `json:"audioSensitivity"`
|
||||
AudioSmoothing float64 `json:"audioSmoothing"`
|
||||
AudioColorScheme string `json:"audioColorScheme"`
|
||||
}
|
||||
|
||||
const defaultZodiac = "射手座"
|
||||
@@ -116,11 +121,15 @@ func loadConfig() *Config {
|
||||
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
Zodiac: defaultZodiac,
|
||||
WallpaperType: WPTheme,
|
||||
Theme: ThemeAurora,
|
||||
Color1: "#1a1a2e",
|
||||
Color2: "#16213e",
|
||||
Zodiac: defaultZodiac,
|
||||
WallpaperType: WPTheme,
|
||||
Theme: ThemeAurora,
|
||||
Color1: "#1a1a2e",
|
||||
Color2: "#16213e",
|
||||
AudioVizStyle: "bars",
|
||||
AudioSensitivity: 1.5,
|
||||
AudioSmoothing: 0.7,
|
||||
AudioColorScheme: "neon",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
settings.go
42
settings.go
@@ -81,6 +81,7 @@ var themeNames = []struct {
|
||||
{ThemeParticle, "粒子"},
|
||||
{ThemeFractal, "极光流体"},
|
||||
{ThemeText, "文字"},
|
||||
{ThemeAudioViz, "音频可视化"},
|
||||
}
|
||||
|
||||
func refreshVisibleCards(cfg, oldCfg *Config) {
|
||||
@@ -198,6 +199,10 @@ func openSettingsWindow() {
|
||||
"photoCard": !cfg.HidePhoto,
|
||||
"photoFrameMode": cfg.PhotoFrameMode,
|
||||
"autoStart": isAutoStartEnabled(),
|
||||
"audioVizStyle": cfg.AudioVizStyle,
|
||||
"audioSensitivity": cfg.AudioSensitivity,
|
||||
"audioSmoothing": cfg.AudioSmoothing,
|
||||
"audioColorScheme": cfg.AudioColorScheme,
|
||||
})
|
||||
return string(data)
|
||||
})
|
||||
@@ -492,6 +497,43 @@ func openSettingsWindow() {
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioVizStyle", func(style string) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioVizStyle = style
|
||||
saveConfig(cfg)
|
||||
if cfg.Theme == ThemeAudioViz {
|
||||
evalJS(fmt.Sprintf(`window.__audioVizStyle=%q; if(window.setAudioVizStyle) setAudioVizStyle(%q)`, style, style))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioSensitivity", func(val float64) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioSensitivity = val
|
||||
saveConfig(cfg)
|
||||
if cfg.Theme == ThemeAudioViz {
|
||||
evalJS(fmt.Sprintf(`window.__audioVizSensitivity=%f; if(window.setAudioSensitivity) setAudioSensitivity(%f)`, val, val))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioSmoothing", func(val float64) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioSmoothing = val
|
||||
saveConfig(cfg)
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioColorScheme", func(scheme string) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioColorScheme = scheme
|
||||
saveConfig(cfg)
|
||||
if cfg.Theme == ThemeAudioViz {
|
||||
evalJS(fmt.Sprintf(`window.__audioVizColors=%q; if(window.setAudioColorScheme) setAudioColorScheme(%q)`, scheme, scheme))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
w.SetHtml(settingsHTML)
|
||||
|
||||
hwnd := uintptr(w.Window())
|
||||
|
||||
@@ -79,6 +79,15 @@ func onSystrayReady() {
|
||||
go knowledgeLoop()
|
||||
go startPhotoLoop()
|
||||
go runAutoUpdateCheck()
|
||||
|
||||
// 音频可视化:启动后检查是否需要音频捕获
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
cfg := loadConfig()
|
||||
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeAudioViz {
|
||||
startAudioCapture()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startWebView() {
|
||||
|
||||
23
wallpaper.go
23
wallpaper.go
@@ -33,6 +33,9 @@ var themeFractal string
|
||||
//go:embed web/themes/text.html
|
||||
var themeText string
|
||||
|
||||
//go:embed web/themes/audio-viz.html
|
||||
var themeAudioViz string
|
||||
|
||||
var themeMap = map[ThemeName]string{
|
||||
ThemeAurora: themeAurora,
|
||||
ThemeStar: themeStarfield,
|
||||
@@ -40,6 +43,7 @@ var themeMap = map[ThemeName]string{
|
||||
ThemeParticle: themeParticles,
|
||||
ThemeFractal: themeFractal,
|
||||
ThemeText: themeText,
|
||||
ThemeAudioViz: themeAudioViz,
|
||||
}
|
||||
|
||||
func buildWallpaperHTML(cfg *Config) string {
|
||||
@@ -88,6 +92,14 @@ func buildWallpaperHTML(cfg *Config) string {
|
||||
inject += fmt.Sprintf(`<script>window.wallpaperText=%s;</script>`, string(escaped))
|
||||
}
|
||||
|
||||
// 注入音频可视化配置
|
||||
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeAudioViz {
|
||||
inject += fmt.Sprintf(
|
||||
`<script>window.__audioVizStyle=%q;window.__audioVizSensitivity=%f;window.__audioVizColors=%q;</script>`,
|
||||
cfg.AudioVizStyle, cfg.AudioSensitivity, cfg.AudioColorScheme,
|
||||
)
|
||||
}
|
||||
|
||||
return strings.Replace(overlayHTML, "</head>", inject+"</head>", 1)
|
||||
}
|
||||
|
||||
@@ -118,6 +130,17 @@ func reloadWallpaper() {
|
||||
}
|
||||
cfg := loadConfig()
|
||||
|
||||
// 音频捕获生命周期
|
||||
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeAudioViz {
|
||||
if !audioActiveState() {
|
||||
startAudioCapture()
|
||||
}
|
||||
} else {
|
||||
if audioActiveState() {
|
||||
stopAudioCapture()
|
||||
}
|
||||
}
|
||||
|
||||
// 非主题壁纸切换:仅替换 #bg-layer,不销毁卡片状态
|
||||
if cfg.WallpaperType != WPTheme {
|
||||
updateBackground(cfg)
|
||||
|
||||
@@ -67,4 +67,12 @@ export function registerGoBridge() {
|
||||
w.__updateBackgroundHtml = (html: string) => {
|
||||
state.backgroundHtml = html
|
||||
}
|
||||
|
||||
// 音频可视化:频率数据直接写入 window,不经过 Vue reactive
|
||||
w.updateAudioDataFromGo = (data: any) => {
|
||||
const parsed = parseArg(data)
|
||||
if (Array.isArray(parsed)) {
|
||||
;(window as any).__audioBins = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const s = reactive<SettingsData>({
|
||||
bingAutoRefresh: false, knowledgeKeyword: '', knowledgePrompt: '',
|
||||
wallpaperText: '', imagePath: '', showSeconds: true, ainewsCard: true,
|
||||
photoDir: '', photoInterval: 15, photoCard: true, photoFrameMode: false, autoStart: false,
|
||||
audioVizStyle: 'bars', audioSensitivity: 1.5, audioSmoothing: 0.7, audioColorScheme: 'neon',
|
||||
})
|
||||
|
||||
// Shared state for color section
|
||||
|
||||
@@ -30,6 +30,43 @@
|
||||
placeholder="输入显示文字"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
<!-- 音频可视化设置 -->
|
||||
<template v-if="s.theme === 'audio-viz'">
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">可视化样式</div>
|
||||
<div class="flex gap-1">
|
||||
<button v-for="st in audioVizStyles" :key="st.value"
|
||||
@click="s.audioVizStyle = st.value; go.saveAudioVizStyle(st.value)"
|
||||
:class="s.audioVizStyle === st.value
|
||||
? 'bg-[var(--accent)] border-[var(--accent)] text-[var(--text-strong)]'
|
||||
: 'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--text)] hover:bg-[var(--card-border)]'"
|
||||
class="border rounded-md text-[11px] py-0.5 px-2 font-[inherit] cursor-pointer transition-colors">{{ st.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">配色方案</div>
|
||||
<div class="flex gap-1">
|
||||
<button v-for="c in audioColors" :key="c.value"
|
||||
@click="s.audioColorScheme = c.value; go.saveAudioColorScheme(c.value)"
|
||||
:class="s.audioColorScheme === c.value
|
||||
? 'bg-[var(--accent)] border-[var(--accent)] text-[var(--text-strong)]'
|
||||
: 'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--text)] hover:bg-[var(--card-border)]'"
|
||||
class="border rounded-md text-[11px] py-0.5 px-2 font-[inherit] cursor-pointer transition-colors">{{ c.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">灵敏度</div>
|
||||
<input type="range" min="0.5" max="3" step="0.1" :value="s.audioSensitivity"
|
||||
@input="s.audioSensitivity = +($event.target as HTMLInputElement).value; debounced('as', go.saveAudioSensitivity, s.audioSensitivity)"
|
||||
class="w-[100px]">
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">平滑度</div>
|
||||
<input type="range" min="0" max="0.95" step="0.05" :value="s.audioSmoothing"
|
||||
@input="s.audioSmoothing = +($event.target as HTMLInputElement).value; debounced('am', go.saveAudioSmoothing, s.audioSmoothing)"
|
||||
class="w-[100px]">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 本地图片 -->
|
||||
@@ -126,6 +163,19 @@ const wpTypes = [
|
||||
{ value: 'color', label: '纯色/渐变' },
|
||||
]
|
||||
|
||||
const audioVizStyles = [
|
||||
{ value: 'bars', label: '柱状' },
|
||||
{ value: 'waveform', label: '波形' },
|
||||
{ value: 'circular', label: '环形' },
|
||||
]
|
||||
|
||||
const audioColors = [
|
||||
{ value: 'neon', label: '霓虹' },
|
||||
{ value: 'ocean', label: '海洋' },
|
||||
{ value: 'fire', label: '火焰' },
|
||||
{ value: 'rainbow', label: '彩虹' },
|
||||
]
|
||||
|
||||
// Bing
|
||||
const bing = ref<BingState>({ date: '', title: '', copyright: '', url: '', fav: false, idx: 0, total: 0 })
|
||||
const bingThumb = ref('')
|
||||
|
||||
@@ -29,4 +29,8 @@ export const go = {
|
||||
clearPhotoDir: () => w.clearPhotoDir(),
|
||||
savePhotoInterval: (val: number) => w.savePhotoInterval(val),
|
||||
resizeToFit: (w: number, h: number) => w.resizeToFit(w, h),
|
||||
saveAudioVizStyle: (style: string) => w.saveAudioVizStyle(style),
|
||||
saveAudioSensitivity: (val: number) => w.saveAudioSensitivity(val),
|
||||
saveAudioSmoothing: (val: number) => w.saveAudioSmoothing(val),
|
||||
saveAudioColorScheme: (scheme: string) => w.saveAudioColorScheme(scheme),
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ export interface SettingsData {
|
||||
photoCard: boolean
|
||||
photoFrameMode: boolean
|
||||
autoStart: boolean
|
||||
audioVizStyle: string
|
||||
audioSensitivity: number
|
||||
audioSmoothing: number
|
||||
audioColorScheme: string
|
||||
}
|
||||
|
||||
export interface BingState {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
262
web/themes/audio-viz.html
Normal file
262
web/themes/audio-viz.html
Normal file
@@ -0,0 +1,262 @@
|
||||
<canvas id="canvas" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:1;"></canvas>
|
||||
<script>
|
||||
var canvas = document.getElementById('canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var audioBins = new Array(128).fill(0);
|
||||
var vizStyle = window.__audioVizStyle || 'bars';
|
||||
var sensitivity = window.__audioVizSensitivity || 1.5;
|
||||
var colorScheme = window.__audioVizColors || 'neon';
|
||||
var idleTime = 0;
|
||||
var lastBinSum = 0;
|
||||
|
||||
// 暴露配置热更新接口
|
||||
window.setAudioVizStyle = function(s) { vizStyle = s; };
|
||||
window.setAudioSensitivity = function(s) { sensitivity = s; };
|
||||
window.setAudioColorScheme = function(s) { colorScheme = s; };
|
||||
|
||||
window.updateAudioDataFromGo = function(data) {
|
||||
for (var i = 0; i < Math.min(data.length, audioBins.length); i++) {
|
||||
audioBins[i] = data[i];
|
||||
}
|
||||
};
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// 配色方案
|
||||
function getColor(i, total, alpha) {
|
||||
var t = i / total;
|
||||
switch (colorScheme) {
|
||||
case 'ocean':
|
||||
return 'hsla(' + (180 + t * 60) + ',70%,60%,' + alpha + ')';
|
||||
case 'fire':
|
||||
return 'hsla(' + (t * 60) + ',90%,55%,' + alpha + ')';
|
||||
case 'rainbow':
|
||||
return 'hsla(' + (t * 360) + ',80%,60%,' + alpha + ')';
|
||||
default: // neon
|
||||
return 'hsla(' + (220 + t * 80) + ',80%,60%,' + alpha + ')';
|
||||
}
|
||||
}
|
||||
|
||||
function getColorRGB(i, total) {
|
||||
var t = i / total;
|
||||
switch (colorScheme) {
|
||||
case 'ocean':
|
||||
return {r: 0, g: Math.floor(128 + t * 127), b: Math.floor(200 + t * 55)};
|
||||
case 'fire':
|
||||
return {r: Math.floor(200 + t * 55), g: Math.floor(t * 180), b: 0};
|
||||
case 'rainbow':
|
||||
var h = t * 360, s = 0.8, l = 0.6;
|
||||
var c = (1 - Math.abs(2*l - 1)) * s;
|
||||
var x = c * (1 - Math.abs((h/60)%2 - 1));
|
||||
var m = l - c/2;
|
||||
var r=0, g=0, b=0;
|
||||
if(h<60){r=c;g=x;}else if(h<120){r=x;g=c;}else if(h<180){g=c;b=x;}
|
||||
else if(h<240){g=x;b=c;}else if(h<300){r=x;b=c;}else{r=c;b=x;}
|
||||
return {r:Math.floor((r+m)*255), g:Math.floor((g+m)*255), b:Math.floor((b+m)*255)};
|
||||
default:
|
||||
return {r: Math.floor(80 + t * 100), g: Math.floor(50 + t * 80), b: Math.floor(180 + t * 75)};
|
||||
}
|
||||
}
|
||||
|
||||
// === Bars ===
|
||||
function renderBars(w, h) {
|
||||
var count = audioBins.length;
|
||||
var barW = (w * 0.85) / count;
|
||||
var gap = barW * 0.15;
|
||||
var actualW = barW - gap;
|
||||
var offsetX = w * 0.075;
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
var val = Math.min(1, audioBins[i] * sensitivity);
|
||||
if (val < 0.01) continue;
|
||||
var barH = val * h * 0.65;
|
||||
var x = offsetX + i * barW;
|
||||
var y = h - barH;
|
||||
|
||||
var grad = ctx.createLinearGradient(x, y, x, h);
|
||||
grad.addColorStop(0, getColor(i, count, 0.95));
|
||||
grad.addColorStop(1, getColor(i, count, 0.15));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(x, y, actualW, barH);
|
||||
|
||||
// 顶部高光
|
||||
ctx.shadowColor = getColor(i, count, 0.6);
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.fillRect(x, y, actualW, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 底部反射
|
||||
var refGrad = ctx.createLinearGradient(x, h, x, h + barH * 0.3);
|
||||
refGrad.addColorStop(0, getColor(i, count, 0.15));
|
||||
refGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = refGrad;
|
||||
ctx.fillRect(x, h, actualW, barH * 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// === Waveform ===
|
||||
function renderWaveform(w, h) {
|
||||
var count = audioBins.length;
|
||||
var midY = h * 0.5;
|
||||
var amplitude = h * 0.35 * sensitivity;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, midY);
|
||||
for (var i = 0; i < count; i++) {
|
||||
var x = (i / (count - 1)) * w;
|
||||
var val = audioBins[i] * amplitude;
|
||||
var y = midY - val;
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
var prevX = ((i - 1) / (count - 1)) * w;
|
||||
var cpx = (prevX + x) / 2;
|
||||
ctx.quadraticCurveTo(prevX, midY - audioBins[i-1] * amplitude, cpx, (midY - audioBins[i-1] * amplitude + y) / 2);
|
||||
}
|
||||
}
|
||||
ctx.lineTo(w, midY);
|
||||
|
||||
// 镜像
|
||||
ctx.moveTo(0, midY);
|
||||
for (var i = 0; i < count; i++) {
|
||||
var x = (i / (count - 1)) * w;
|
||||
var val = audioBins[i] * amplitude * 0.6;
|
||||
var y = midY + val;
|
||||
if (i === 0) {
|
||||
ctx.lineTo(x, y);
|
||||
} else {
|
||||
var prevX = ((i - 1) / (count - 1)) * w;
|
||||
var cpx = (prevX + x) / 2;
|
||||
ctx.quadraticCurveTo(prevX, midY + audioBins[i-1] * amplitude * 0.6, cpx, (midY + audioBins[i-1] * amplitude * 0.6 + y) / 2);
|
||||
}
|
||||
}
|
||||
ctx.lineTo(w, midY);
|
||||
|
||||
ctx.strokeStyle = getColor(Math.floor(count / 2), count, 0.8);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowColor = getColor(Math.floor(count / 2), count, 0.5);
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 填充区域
|
||||
ctx.lineTo(w, midY);
|
||||
ctx.lineTo(0, midY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = getColor(Math.floor(count / 2), count, 0.08);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// === Circular ===
|
||||
function renderCircular(w, h) {
|
||||
var cx = w / 2;
|
||||
var cy = h / 2;
|
||||
var count = audioBins.length;
|
||||
var baseR = Math.min(w, h) * 0.15;
|
||||
var maxR = Math.min(w, h) * 0.4 * sensitivity;
|
||||
|
||||
for (var i = 0; i < count; i++) {
|
||||
var angle = (i / count) * Math.PI * 2 - Math.PI / 2;
|
||||
var val = Math.min(1, audioBins[i]);
|
||||
var r = baseR + val * maxR;
|
||||
var x1 = cx + Math.cos(angle) * baseR;
|
||||
var y1 = cy + Math.sin(angle) * baseR;
|
||||
var x2 = cx + Math.cos(angle) * r;
|
||||
var y2 = cy + Math.sin(angle) * r;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.strokeStyle = getColor(i, count, 0.7 + val * 0.3);
|
||||
ctx.lineWidth = Math.max(1, (Math.PI * 2 * baseR / count) * 0.6);
|
||||
ctx.shadowColor = getColor(i, count, 0.4);
|
||||
ctx.shadowBlur = val > 0.3 ? 8 : 0;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// 中心光晕
|
||||
var avgBin = 0;
|
||||
for (var i = 0; i < count; i++) avgBin += audioBins[i];
|
||||
avgBin /= count;
|
||||
if (avgBin > 0.05) {
|
||||
var glowR = baseR * (0.8 + avgBin * 0.5);
|
||||
var glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR);
|
||||
glow.addColorStop(0, getColor(Math.floor(count/2), count, avgBin * 0.3));
|
||||
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, glowR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// === Idle 呼吸动画 ===
|
||||
function renderIdle(w, h, ts) {
|
||||
var cx = w / 2;
|
||||
var cy = h / 2;
|
||||
var t = ts / 1000;
|
||||
var breathR = Math.min(w, h) * 0.15 * (1 + 0.15 * Math.sin(t * 0.8));
|
||||
|
||||
var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, breathR);
|
||||
grad.addColorStop(0, getColor(0, 1, 0.15 + 0.05 * Math.sin(t)));
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, breathR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 微弱粒子
|
||||
for (var i = 0; i < 6; i++) {
|
||||
var angle = (i / 6) * Math.PI * 2 + t * 0.3;
|
||||
var dist = breathR * (1.5 + 0.3 * Math.sin(t * 1.2 + i));
|
||||
var px = cx + Math.cos(angle) * dist;
|
||||
var py = cy + Math.sin(angle) * dist;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = getColor(i, 6, 0.2 + 0.1 * Math.sin(t + i));
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
var FPS = 30, lastFrame = 0;
|
||||
|
||||
function render(ts) {
|
||||
requestAnimationFrame(render);
|
||||
if (ts - lastFrame < 1000 / FPS) return;
|
||||
lastFrame = ts;
|
||||
|
||||
var w = canvas.width, h = canvas.height;
|
||||
|
||||
// 半透明背景实现拖尾效果
|
||||
ctx.fillStyle = 'rgba(5, 5, 15, 0.25)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 检测空闲
|
||||
var binSum = 0;
|
||||
for (var i = 0; i < audioBins.length; i++) binSum += audioBins[i];
|
||||
if (binSum < 0.5) {
|
||||
idleTime++;
|
||||
} else {
|
||||
idleTime = 0;
|
||||
}
|
||||
|
||||
if (idleTime > 60) { // ~2 seconds at 30fps
|
||||
renderIdle(w, h, ts);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (vizStyle) {
|
||||
case 'waveform': renderWaveform(w, h); break;
|
||||
case 'circular': renderCircular(w, h); break;
|
||||
default: renderBars(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(render);
|
||||
</script>
|
||||
Reference in New Issue
Block a user