新增: 音频可视化8种主题+节拍检测+交互效果

This commit is contained in:
lxy
2026-06-05 09:50:32 +08:00
parent 1f892fc390
commit 004dba7db6
5 changed files with 1224 additions and 188 deletions
+52 -37
View File
@@ -74,33 +74,55 @@ func fftMagnitude(input []float32) []float64 {
return mag return mag
} }
// downsampleBins: 分段映射 — 低频线性保细节,中高频对数保覆盖
// 不再做额外 tilt 补偿,映射本身就是均衡的
func downsampleBins(magnitudes []float64, target int) []float64 { func downsampleBins(magnitudes []float64, target int) []float64 {
srcLen := len(magnitudes) srcLen := len(magnitudes)
if srcLen == 0 || target == 0 { if srcLen == 0 || target == 0 {
return make([]float64, target) return make([]float64, target)
} }
// 低频线性区间:前20%的 display bins 覆盖 FFT 1-80 (约47Hz-3.7kHz)
// 中高频对数区间:后80%覆盖 FFT 80-512 (3.7kHz-24kHz)
const lowEnd = 80 // 低频截止 FFT 索引
const lowFrac = 0.2
bins := make([]float64, target) bins := make([]float64, target)
for i := 0; i < target; i++ { for i := 0; i < target; i++ {
frac := float64(i) / float64(target) frac := float64(i) / float64(target)
start := int(math.Pow(float64(srcLen), frac))
fracNext := float64(i+1) / float64(target) fracNext := float64(i+1) / float64(target)
end := int(math.Pow(float64(srcLen), fracNext))
var start, end int
if frac < lowFrac {
start = int(frac / lowFrac * float64(lowEnd))
end = int(fracNext / lowFrac * float64(lowEnd))
} else {
f := (frac - lowFrac) / (1 - lowFrac)
fNext := (fracNext - lowFrac) / (1 - lowFrac)
start = lowEnd + int(float64(srcLen-lowEnd)*math.Pow(f, 0.7))
end = lowEnd + int(float64(srcLen-lowEnd)*math.Pow(fNext, 0.7))
}
if end <= start { if end <= start {
end = start + 1 end = start + 1
} }
if end > srcLen { if end > srcLen {
end = srcLen end = srcLen
} }
sum := 0.0
// 取最大值保留峰值冲击力
maxVal := 0.0
for j := start; j < end; j++ { for j := start; j < end; j++ {
sum += magnitudes[j] if magnitudes[j] > maxVal {
maxVal = magnitudes[j]
} }
bins[i] = sum / float64(end-start) }
bins[i] = maxVal
} }
return bins return bins
} }
func smoothBins(prev, curr []float64, factor float64) []float64 { func smoothBinsGo(prev, curr []float64, factor float64) []float64 {
n := len(curr) n := len(curr)
if len(prev) != n { if len(prev) != n {
return curr return curr
@@ -122,7 +144,6 @@ func comCall(obj uintptr, vtableIdx uintptr, args ...uintptr) uintptr {
} }
vtable := *(*uintptr)(unsafe.Pointer(obj)) vtable := *(*uintptr)(unsafe.Pointer(obj))
method := *(*uintptr)(unsafe.Pointer(vtable + vtableIdx*unsafe.Sizeof(uintptr(0)))) method := *(*uintptr)(unsafe.Pointer(vtable + vtableIdx*unsafe.Sizeof(uintptr(0))))
// 构建参数数组 (this + args),最多支持 12 参数
a := make([]uintptr, 12) a := make([]uintptr, 12)
a[0] = obj a[0] = obj
for i, arg := range args { for i, arg := range args {
@@ -155,17 +176,14 @@ var (
iidIAudioCaptureClient = windows.GUID{Data1: 0xC8ADBD64, Data2: 0xE71E, Data3: 0x48A0, Data4: [8]byte{0xA4, 0xDE, 0x18, 0x5C, 0x39, 0x5C, 0xD3, 0x17}} 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 ( const (
enumeratorGetDefaultAudioEndpoint = 4 // 第 4 个方法 (0-based from vtable start) enumeratorGetDefaultAudioEndpoint = 4
) )
// IMMDevice vtable
const ( const (
deviceActivate = 3 // 第 3 个方法 deviceActivate = 3
) )
// IAudioClient vtable
const ( const (
audioClientInitialize = 3 audioClientInitialize = 3
audioClientGetBufferSize = 4 audioClientGetBufferSize = 4
@@ -175,7 +193,6 @@ const (
audioClientGetService = 14 audioClientGetService = 14
) )
// IAudioCaptureClient vtable
const ( const (
captureGetBuffer = 3 captureGetBuffer = 3
captureReleaseBuffer = 4 captureReleaseBuffer = 4
@@ -191,6 +208,7 @@ var (
audioDone chan struct{} audioDone chan struct{}
audioActive bool audioActive bool
smoothed []float64 smoothed []float64
runningMax float64
) )
func startAudioCapture() { func startAudioCapture() {
@@ -203,6 +221,7 @@ func startAudioCapture() {
audioDone = make(chan struct{}) audioDone = make(chan struct{})
audioActive = true audioActive = true
smoothed = nil smoothed = nil
runningMax = 0
go audioCaptureLoop() go audioCaptureLoop()
log.Println("音频捕获: 已启动") log.Println("音频捕获: 已启动")
} }
@@ -236,15 +255,13 @@ func audioCaptureLoop() {
runtime.LockOSThread() runtime.LockOSThread()
defer runtime.UnlockOSThread() defer runtime.UnlockOSThread()
// COM 初始化
ole32dll.NewProc("CoInitializeEx").Call(0, 0) ole32dll.NewProc("CoInitializeEx").Call(0, 0)
defer ole32dll.NewProc("CoUninitialize").Call() defer ole32dll.NewProc("CoUninitialize").Call()
// CoCreateInstance -> IMMDeviceEnumerator
var enumerator uintptr var enumerator uintptr
hr, _, _ := ole32dll.NewProc("CoCreateInstance").Call( hr, _, _ := ole32dll.NewProc("CoCreateInstance").Call(
uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)), uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)),
0, 1, // CLSCTX_INPROC_SERVER 0, 1,
uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)), uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)),
uintptr(unsafe.Pointer(&enumerator)), uintptr(unsafe.Pointer(&enumerator)),
) )
@@ -255,7 +272,6 @@ func audioCaptureLoop() {
} }
defer comRelease(enumerator) defer comRelease(enumerator)
// enumerator->GetDefaultAudioEndpoint(eRender=0, eConsole=0)
var device uintptr var device uintptr
hr = comCall(enumerator, enumeratorGetDefaultAudioEndpoint, 0, 0, uintptr(unsafe.Pointer(&device))) hr = comCall(enumerator, enumeratorGetDefaultAudioEndpoint, 0, 0, uintptr(unsafe.Pointer(&device)))
if hr != 0 || device == 0 { if hr != 0 || device == 0 {
@@ -265,7 +281,6 @@ func audioCaptureLoop() {
} }
defer comRelease(device) defer comRelease(device)
// device->Activate(IID_IAudioClient, CLSCTX_INPROC=1, NULL, &audioClient)
var audioClient uintptr var audioClient uintptr
hr = comCall(device, deviceActivate, hr = comCall(device, deviceActivate,
uintptr(unsafe.Pointer(&iidIAudioClient)), uintptr(unsafe.Pointer(&iidIAudioClient)),
@@ -279,7 +294,6 @@ func audioCaptureLoop() {
} }
defer comRelease(audioClient) defer comRelease(audioClient)
// audioClient->GetMixFormat(&pwfx)
var pwfx uintptr var pwfx uintptr
hr = comCall(audioClient, audioClientGetMixFormat, uintptr(unsafe.Pointer(&pwfx))) hr = comCall(audioClient, audioClientGetMixFormat, uintptr(unsafe.Pointer(&pwfx)))
if hr != 0 || pwfx == 0 { if hr != 0 || pwfx == 0 {
@@ -293,11 +307,10 @@ func audioCaptureLoop() {
nSamplesPerSec := *(*uint32)(unsafe.Pointer(pwfx + 4)) nSamplesPerSec := *(*uint32)(unsafe.Pointer(pwfx + 4))
log.Printf("音频捕获: 格式 ch=%d rate=%d", nChannels, nSamplesPerSec) log.Printf("音频捕获: 格式 ch=%d rate=%d", nChannels, nSamplesPerSec)
// audioClient->Initialize(SHARED, LOOPBACK, 1s buffer, 0, pwfx, NULL)
hr = comCall(audioClient, audioClientInitialize, hr = comCall(audioClient, audioClientInitialize,
0, // AUDCLNT_SHAREMODE_SHARED 0,
0x00020000, // AUDCLNT_STREAMFLAGS_LOOPBACK 0x00020000,
10000000, // 1 second (100ns units) 10000000,
0, 0,
pwfx, pwfx,
0, 0,
@@ -308,7 +321,6 @@ func audioCaptureLoop() {
return return
} }
// audioClient->GetService(IID_IAudioCaptureClient, &captureClient)
var captureClient uintptr var captureClient uintptr
hr = comCall(audioClient, audioClientGetService, hr = comCall(audioClient, audioClientGetService,
uintptr(unsafe.Pointer(&iidIAudioCaptureClient)), uintptr(unsafe.Pointer(&iidIAudioCaptureClient)),
@@ -321,11 +333,9 @@ func audioCaptureLoop() {
} }
defer comRelease(captureClient) defer comRelease(captureClient)
// audioClient->Start()
comCall(audioClient, audioClientStart) comCall(audioClient, audioClientStart)
defer comCall(audioClient, audioClientStop) defer comCall(audioClient, audioClientStop)
// 环形缓冲区 (~100ms)
ch := int(nChannels) ch := int(nChannels)
bufSize := int(nSamplesPerSec)/10*ch + fftSize*ch bufSize := int(nSamplesPerSec)/10*ch + fftSize*ch
ringBuf := make([]float32, bufSize) ringBuf := make([]float32, bufSize)
@@ -337,6 +347,9 @@ func audioCaptureLoop() {
smoothingFactor = 0.7 smoothingFactor = 0.7
} }
// 预分配 mono slice
mono := make([]float32, fftSize)
ticker := time.NewTicker(33 * time.Millisecond) ticker := time.NewTicker(33 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
@@ -348,7 +361,6 @@ func audioCaptureLoop() {
case <-ticker.C: case <-ticker.C:
} }
// 读取 WASAPI 缓冲区
for { for {
var pData, nFrames, flags uintptr var pData, nFrames, flags uintptr
hr = comCall(captureClient, captureGetBuffer, hr = comCall(captureClient, captureGetBuffer,
@@ -368,8 +380,6 @@ func audioCaptureLoop() {
comCall(captureClient, captureReleaseBuffer, uintptr(nFrames)) comCall(captureClient, captureReleaseBuffer, uintptr(nFrames))
} }
// 取 fftSize 个单声道样本
mono := make([]float32, fftSize)
for i := 0; i < fftSize; i++ { for i := 0; i < fftSize; i++ {
pos := (writePos - fftSize*ch + i*ch + bufSize) % bufSize pos := (writePos - fftSize*ch + i*ch + bufSize) % bufSize
var sum float32 var sum float32
@@ -383,20 +393,25 @@ func audioCaptureLoop() {
bins := downsampleBins(mag, targetBins) bins := downsampleBins(mag, targetBins)
if smoothed != nil { if smoothed != nil {
bins = smoothBins(smoothed, bins, smoothingFactor) bins = smoothBinsGo(smoothed, bins, smoothingFactor)
} }
smoothed = bins smoothed = bins
// 归一化 // 全局归一化(慢衰减 runningMax
maxVal := 0.0 curMax := 0.0
for _, v := range bins { for _, v := range bins {
if v > maxVal { if v > curMax {
maxVal = v curMax = v
} }
} }
if maxVal > 0.001 { if curMax > runningMax {
runningMax = curMax
} else {
runningMax *= 0.985
}
if runningMax > 0.001 {
for i := range bins { for i := range bins {
bins[i] /= maxVal bins[i] /= runningMax
} }
} }
@@ -407,6 +422,6 @@ func audioCaptureLoop() {
func comRelease(obj uintptr) { func comRelease(obj uintptr) {
if obj != 0 { if obj != 0 {
comCall(obj, 2) // IUnknown::Release = vtable index 2 comCall(obj, 2)
} }
} }
@@ -34,7 +34,7 @@
<template v-if="s.theme === 'audio-viz'"> <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="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="text-xs font-medium text-[var(--text-muted)]">可视化样式</div>
<div class="flex gap-1"> <div class="flex flex-wrap gap-1 justify-end">
<button v-for="st in audioVizStyles" :key="st.value" <button v-for="st in audioVizStyles" :key="st.value"
@click="s.audioVizStyle = st.value; go.saveAudioVizStyle(st.value)" @click="s.audioVizStyle = st.value; go.saveAudioVizStyle(st.value)"
:class="s.audioVizStyle === st.value :class="s.audioVizStyle === st.value
@@ -167,6 +167,14 @@ const audioVizStyles = [
{ value: 'bars', label: '柱状' }, { value: 'bars', label: '柱状' },
{ value: 'waveform', label: '波形' }, { value: 'waveform', label: '波形' },
{ value: 'circular', label: '环形' }, { value: 'circular', label: '环形' },
{ value: 'particles', label: '粒子' },
{ value: 'ripples', label: '涟漪' },
{ value: 'text', label: '文字' },
{ value: 'stars', label: '星空' },
{ value: 'dna', label: 'DNA' },
{ value: 'matrix', label: '矩阵' },
{ value: 'jellyfish', label: '水母' },
{ value: 'aurora', label: '极光' },
] ]
const audioColors = [ const audioColors = [
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
File diff suppressed because one or more lines are too long
+1149 -136
View File
File diff suppressed because it is too large Load Diff