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

This commit is contained in:
2026-06-05 09:50:32 +08:00
parent 1f892fc390
commit 004dba7db6
5 changed files with 1224 additions and 188 deletions

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)
} }
} }

View File

@@ -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 = [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff