Skip to content

Commit 69a562a

Browse files
authored
Merge pull request #9 from apoint123/feat/replace-sprctrogram-impl
2 parents f680f38 + 0c5a783 commit 69a562a

25 files changed

+1481
-1206
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
/src/vendors/**/*.d.ts
44
/LICENSE
55
*.dict.json
6+
*.min.js
67
/dist

eslint.config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ export default defineConfigWithVueTs(
1414
files: ['**/*.{vue,ts,mts,tsx}'],
1515
},
1616

17-
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/vendors/**', 'env.d.ts']),
17+
globalIgnores([
18+
'**/dist/**',
19+
'**/dist-ssr/**',
20+
'**/coverage/**',
21+
'**/vendors/**',
22+
'env.d.ts',
23+
'**/*.min.js',
24+
]),
1825

1926
...pluginVue.configs['flat/essential'],
2027
vueTsConfigs.recommended,

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
href="/favicon-dark.svg"
3232
media="(prefers-color-scheme: dark)"
3333
/>
34+
<script src="/coi-serviceworker.min.js"></script>
3435
</head>
3536
<body>
3637
<div id="app"></div>

public/coi-serviceworker.min.js

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/audio/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ export const audioEngine = {
188188
volumeRef,
189189
playbackRateRef,
190190
activatedRef,
191+
get audioBuffer() {
192+
return audioBufferRef.value
193+
},
191194
audioBufferComputed: readonly(audioBufferRef),
192195
filenameComputed: readonly(filenameRef),
193196
rawFileComputed: readonly(rawFileRef),
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { type InjectionKey, type Ref, type ShallowRef, computed, inject, provide, ref } from 'vue'
2+
3+
import { generatePalette, getIcyBlueColor } from '@core/spectrogram/colors'
4+
5+
/**
6+
* `SpectrogramContext` 用于在不同组件中统一管理和共享状态,并自动处理复杂的坐标转换
7+
*/
8+
export interface SpectrogramContext {
9+
/**
10+
* 横向滚动距离
11+
*
12+
* 表示当前视口左侧距离整个频谱图最左侧(0秒)有多少像素
13+
*/
14+
scrollLeft: Ref<number>
15+
/**
16+
* 缩放层级
17+
*
18+
* 每秒钟占用多少像素,数值越大放得越大
19+
*/
20+
zoom: Ref<number>
21+
/**
22+
* 可视容器宽度
23+
*
24+
* 浏览器中那个 div 的实际物理宽度
25+
*/
26+
containerWidth: Ref<number>
27+
/**
28+
* 鼠标 X 坐标
29+
*
30+
* 相对于容器左侧的距离
31+
*/
32+
mouseX: Ref<number>
33+
/**
34+
* 鼠标当前是否正悬停在容器上
35+
*/
36+
isHovering: Ref<boolean>
37+
38+
/**
39+
* 频谱图的增益值
40+
*/
41+
gain: Ref<number>
42+
/**
43+
* 频谱图调色板数据
44+
*/
45+
palette: Ref<Uint8Array>
46+
/**
47+
* 频谱图调色板的 ID
48+
*/
49+
paletteId: Ref<string>
50+
/**
51+
* 容器的 CSS 高度,用于 CSS 布局
52+
*/
53+
displayHeight: Ref<number>
54+
/**
55+
* 瓦片的实际渲染高度,用于 Canvas
56+
*/
57+
renderHeight: Ref<number>
58+
59+
/**
60+
* 音频总时长
61+
*/
62+
duration: Ref<number>
63+
64+
/**
65+
* Spectrogram.vue 的内容层 div 宽度,用来产生滚动效果
66+
*/
67+
totalContentWidth: Ref<number>
68+
/**
69+
* 视口起始时间
70+
*
71+
* 表示当前屏幕最左边对应的是音频的第几秒
72+
*/
73+
viewStartTime: Ref<number>
74+
/**
75+
* 视口结束时间
76+
*
77+
* 表示当前屏幕最右边对应的是音频的第几秒
78+
*/
79+
viewEndTime: Ref<number>
80+
/**
81+
* 鼠标指的是多少秒
82+
*/
83+
hoverTime: Ref<number>
84+
/**
85+
* zoom 的别名
86+
* @see {@link zoom}
87+
*/
88+
pixelsPerSecond: Ref<number>
89+
90+
/**
91+
* 设置调色板数据的 Action
92+
* @param name: 频谱图调色板的名称
93+
* @param colorGenerator 调色板生成器函数
94+
* @returns 调色板 RGB 数据
95+
*/
96+
setPalette: (name: string, colorGenerator: (t: number) => [number, number, number]) => void
97+
}
98+
99+
const SpectrogramContextKey: InjectionKey<SpectrogramContext> = Symbol('SpectrogramContext')
100+
101+
interface SpectrogramProviderOptions {
102+
audioBufferComputed: Readonly<ShallowRef<AudioBuffer | null>>
103+
gainModel: Ref<number>
104+
zoomModel: Ref<number>
105+
scrollLeftModel: Ref<number>
106+
paletteModel: Ref<Uint8Array>
107+
}
108+
109+
export function useSpectrogramProvider({
110+
audioBufferComputed,
111+
gainModel,
112+
zoomModel,
113+
scrollLeftModel,
114+
paletteModel,
115+
}: SpectrogramProviderOptions) {
116+
const containerWidth = ref(0)
117+
118+
const mouseX = ref(0)
119+
const isHovering = ref(false)
120+
121+
const gain = gainModel
122+
const zoom = zoomModel
123+
const scrollLeft = scrollLeftModel.value ? scrollLeftModel : ref(0)
124+
const paletteId = ref('icy-blue')
125+
const palette = paletteModel?.value ? paletteModel : ref(generatePalette(getIcyBlueColor))
126+
const displayHeight = ref(240)
127+
const renderHeight = ref(240)
128+
129+
const setPalette = (name: string, colorGenerator: (t: number) => [number, number, number]) => {
130+
paletteId.value = name
131+
palette.value = generatePalette(colorGenerator)
132+
}
133+
134+
const duration = computed(() => audioBufferComputed.value?.duration || 0)
135+
136+
const totalContentWidth = computed(() => duration.value * zoom.value)
137+
138+
const viewStartTime = computed(() => {
139+
if (zoom.value === 0) return 0
140+
return scrollLeft.value / zoom.value
141+
})
142+
143+
const viewEndTime = computed(() => {
144+
if (zoom.value === 0) return 0
145+
return (scrollLeft.value + containerWidth.value) / zoom.value
146+
})
147+
148+
const hoverTime = computed(() => {
149+
// if (!isHovering.value) return -1
150+
if (zoom.value === 0) return 0
151+
const time = (scrollLeft.value + mouseX.value) / zoom.value
152+
return Math.max(0, Math.min(time, duration.value))
153+
})
154+
155+
const context: SpectrogramContext = {
156+
scrollLeft,
157+
zoom,
158+
containerWidth,
159+
mouseX,
160+
isHovering,
161+
gain,
162+
palette,
163+
paletteId,
164+
displayHeight,
165+
renderHeight,
166+
duration,
167+
totalContentWidth,
168+
viewStartTime,
169+
viewEndTime,
170+
hoverTime,
171+
pixelsPerSecond: zoom,
172+
setPalette,
173+
}
174+
175+
provide(SpectrogramContextKey, context)
176+
177+
return context
178+
}
179+
180+
export function useSpectrogramContext() {
181+
const context = inject(SpectrogramContextKey)
182+
if (!context) {
183+
throw new Error('useSpectrogramContext must be used within a Spectrogram provider')
184+
}
185+
return context
186+
}

src/core/spectrogram/colors.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* @description 频谱图调色板的生成器
3+
*/
4+
5+
/**
6+
* @description 渐变色标
7+
*
8+
* @param pos - 位置,从 0.0 到 1.0
9+
* @param color - HEX 颜色字符串
10+
*/
11+
export type ColorStop = {
12+
id: string
13+
pos: number
14+
color: string
15+
}
16+
17+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
18+
if (s === 0.0) {
19+
const gray = (l * 255) | 0
20+
return [gray, gray, gray]
21+
}
22+
23+
const chroma = (1.0 - Math.abs(2.0 * l - 1.0)) * s
24+
const hPrime = h / 60.0
25+
const secondComponent = chroma * (1.0 - Math.abs((hPrime % 2.0) - 1.0))
26+
const lightnessModifier = l - chroma / 2.0
27+
28+
let rPrime = 0,
29+
gPrime = 0,
30+
bPrime = 0
31+
32+
if (hPrime >= 0 && hPrime < 1) {
33+
;[rPrime, gPrime, bPrime] = [chroma, secondComponent, 0.0]
34+
} else if (hPrime >= 1 && hPrime < 2) {
35+
;[rPrime, gPrime, bPrime] = [secondComponent, chroma, 0.0]
36+
} else if (hPrime >= 2 && hPrime < 3) {
37+
;[rPrime, gPrime, bPrime] = [0.0, chroma, secondComponent]
38+
} else if (hPrime >= 3 && hPrime < 4) {
39+
;[rPrime, gPrime, bPrime] = [0.0, secondComponent, chroma]
40+
} else if (hPrime >= 4 && hPrime < 5) {
41+
;[rPrime, gPrime, bPrime] = [secondComponent, 0.0, chroma]
42+
} else if (hPrime >= 5 && hPrime < 6) {
43+
;[rPrime, gPrime, bPrime] = [chroma, 0.0, secondComponent]
44+
}
45+
46+
const r = ((rPrime + lightnessModifier) * 255) | 0
47+
const g = ((gPrime + lightnessModifier) * 255) | 0
48+
const b = ((bPrime + lightnessModifier) * 255) | 0
49+
50+
return [r, g, b]
51+
}
52+
53+
export function getIcyBlueColor(value: number): [number, number, number] {
54+
const v = Math.max(0.0, Math.min(value, 1.0))
55+
const h = ((((-128.0 * v + 191.0) % 256) + 256) % 256) * (360.0 / 255.0)
56+
const s = Math.max(0.0, Math.min(128.0 * v + 127.0, 255.0)) / 255.0
57+
const l = Math.max(0.0, Math.min(255.0 * v, 255.0)) / 255.0
58+
return hslToRgb(h, s, l)
59+
}
60+
61+
export function getGrayscaleColor(value: number): [number, number, number] {
62+
const v = value * 255
63+
return [v, v, v]
64+
}
65+
66+
export function getGreenColor(value: number): [number, number, number] {
67+
const v = Math.max(0.0, Math.min(value, 1.0))
68+
const h = 85.0 * (360.0 / 255.0)
69+
const s = 1.0
70+
const l = Math.max(0.0, Math.min(200.0 * v, 255.0)) / 255.0
71+
return hslToRgb(h, s, l)
72+
}
73+
74+
export function generatePalette(colorFn: (value: number) => [number, number, number]): Uint8Array {
75+
const lut = new Uint8Array(256 * 4)
76+
for (let i = 0; i < 256; i++) {
77+
const [r, g, b] = colorFn(i / 255.0)
78+
const idx = i * 4
79+
lut[idx] = r
80+
lut[idx + 1] = g
81+
lut[idx + 2] = b
82+
lut[idx + 3] = 255
83+
}
84+
return lut
85+
}
86+
87+
/**
88+
* @description 解析 HEX 颜色字符串为 RGB
89+
*/
90+
function parseHexColor(hex: string): [number, number, number] {
91+
const r = parseInt(hex.slice(1, 3), 16)
92+
const g = parseInt(hex.slice(3, 5), 16)
93+
const b = parseInt(hex.slice(5, 7), 16)
94+
return [r, g, b]
95+
}
96+
97+
/**
98+
* @description 线性插值
99+
*/
100+
function lerp(a: number, b: number, t: number): number {
101+
return a * (1 - t) + b * t
102+
}
103+
104+
/**
105+
* @description 从色标生成一个 256 色的 LUT
106+
*
107+
* @param stops 渐变色标
108+
* @returns 一个 1024 字节的 Uint8Array (256 * RGBA)
109+
*/
110+
export function generateLutFromStops(stops: ColorStop[]): Uint8Array {
111+
const lut = new Uint8Array(256 * 4)
112+
113+
if (stops.length === 0) {
114+
return lut
115+
}
116+
117+
const sortedStops = [...stops].sort((a, b) => a.pos - b.pos)
118+
119+
const parsedStops = sortedStops.map((s) => ({
120+
pos: s.pos,
121+
rgb: parseHexColor(s.color),
122+
}))
123+
124+
for (let i = 0; i < 256; i++) {
125+
const currentPos = i / 255.0
126+
127+
// 前面已有 if (stops.length === 0) 检查,所有下面的非空断言都是安全的
128+
let stopA = parsedStops[0]!
129+
let stopB = parsedStops[parsedStops.length - 1]!
130+
131+
if (currentPos <= parsedStops[0]!.pos) {
132+
stopA = parsedStops[0]!
133+
stopB = parsedStops[0]!
134+
} else if (currentPos >= parsedStops[parsedStops.length - 1]!.pos) {
135+
stopA = parsedStops[parsedStops.length - 1]!
136+
stopB = parsedStops[parsedStops.length - 1]!
137+
} else {
138+
for (let j = 0; j < parsedStops.length - 1; j++) {
139+
if (currentPos >= parsedStops[j]!.pos && currentPos <= parsedStops[j + 1]!.pos) {
140+
stopA = parsedStops[j]!
141+
stopB = parsedStops[j + 1]!
142+
break
143+
}
144+
}
145+
}
146+
147+
const range = stopB.pos - stopA.pos
148+
const t = range < 1e-6 ? 0 : (currentPos - stopA.pos) / range
149+
150+
const r = lerp(stopA.rgb[0], stopB.rgb[0], t)
151+
const g = lerp(stopA.rgb[1], stopB.rgb[1], t)
152+
const b = lerp(stopA.rgb[2], stopB.rgb[2], t)
153+
154+
const idx = i * 4
155+
lut[idx] = r
156+
lut[idx + 1] = g
157+
lut[idx + 2] = b
158+
lut[idx + 3] = 255
159+
}
160+
161+
return lut
162+
}

0 commit comments

Comments
 (0)