Repository navigation
feat: voice icon #7824
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: voice icon #7824
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| <script setup lang="ts"> | ||
| import { computed } from 'vue' | ||
|
|
||
| defineOptions({ name: 'VoiceWaveIcon' }) | ||
|
|
||
| const props = withDefaults(defineProps<{ size?: number | string; color?: string }>(), { size: 24 }) | ||
|
|
||
| const waveSize = computed(() => (typeof props.size === 'number' ? `${props.size}px` : props.size)) | ||
| </script> | ||
|
|
||
| <template> | ||
| <span class="voice-wave-icon shrink-0" :style="{ width: waveSize, height: waveSize, color }" aria-hidden="true"> | ||
| <template v-for="barIndex in 5" :key="barIndex"> | ||
| <span class="voice-wave-icon__bar" /> | ||
| </template> | ||
| </span> | ||
| </template> | ||
|
|
||
| <style scoped lang="scss"> | ||
| .voice-wave-icon { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| gap: 10%; | ||
| vertical-align: middle; | ||
|
|
||
| &__bar { | ||
| width: 10%; | ||
| height: 40%; | ||
| flex-shrink: 0; | ||
| border-radius: 999px; | ||
| background: currentColor; | ||
| animation: voice-wave 1s ease-in-out infinite; | ||
|
|
||
| &:nth-child(2), | ||
| &:nth-child(4) { | ||
| height: 70%; | ||
| animation-delay: -0.2s; | ||
| } | ||
|
|
||
| &:nth-child(3) { | ||
| height: 100%; | ||
| animation-delay: -0.4s; | ||
| } | ||
|
|
||
| @media (prefers-reduced-motion: reduce) { | ||
| animation: none; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @keyframes voice-wave { | ||
| 0%, | ||
| 100% { | ||
| transform: scaleY(0.45); | ||
| } | ||
|
|
||
| 50% { | ||
| transform: scaleY(1); | ||
| } | ||
| } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ import { MsgError } from '@/utils/message' | |
| /** 单次录音最长秒数,到时自动结束并转写。 */ | ||
| export const VOICE_RECORD_MAX_SECONDS = 60 | ||
| /** 录音波形柱数量。 */ | ||
| const WAVE_BAR_COUNT = 60 | ||
| const WAVE_BAR_COUNT = 80 | ||
|
|
||
| // 关闭 recorder-core 的控制台日志 | ||
| Recorder.CLog = () => {} | ||
|
|
@@ -50,8 +50,22 @@ export function useVoiceRecorder(onRecorded: (audio: Blob) => void) { | |
| type: 'mp3', | ||
| bitRate: 128, | ||
| sampleRate: 16000, | ||
| onProcess: (_buffers: unknown, powerLevel: number) => { | ||
| waveLevels.value = [...waveLevels.value.slice(1), powerLevel] | ||
| onProcess: (buffers: Int16Array[]) => { | ||
| // 分段计算最新音频的能量,整排随当前声音刷新,静音时收回点状基线。 | ||
| const samples = buffers[buffers.length - 1] | ||
| waveLevels.value = Array.from({ length: WAVE_BAR_COUNT }, (_, index) => { | ||
| if (!samples?.length) return 0 | ||
| const start = Math.floor((index * samples.length) / WAVE_BAR_COUNT) | ||
| const end = Math.floor(((index + 1) * samples.length) / WAVE_BAR_COUNT) | ||
| if (end === start) return 0 | ||
| let energy = 0 | ||
| for (let offset = start; offset < end; offset++) { | ||
| const amplitude = samples[offset]! / 32768 | ||
| energy += amplitude * amplitude | ||
| } | ||
| const volume = Math.sqrt(energy / (end - start)) | ||
| return Math.min(100, Math.max(0, volume - 0.005) * 500) | ||
| }) | ||
| }, | ||
| }) | ||
| recorder = instance | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's my review of the code changes: Issues & Potential Problems1.
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,8 +13,13 @@ const emit = defineEmits<{ | |
| stop: [] | ||
| }>() | ||
|
|
||
| /** 波形柱最大高度(px),与波形容器 h-8 一致。 */ | ||
| const WAVE_MAX_HEIGHT = 32 | ||
| /** 波形在固定高度容器内围绕中线起伏。 */ | ||
| const WAVE_MAX_HEIGHT = 24 | ||
| const WAVE_MIN_HEIGHT = 2 | ||
|
|
||
| const waveHeights = computed(() => | ||
| props.waveLevels.map((level) => WAVE_MIN_HEIGHT + (Math.max(0, Math.min(100, level)) / 100) * (WAVE_MAX_HEIGHT - WAVE_MIN_HEIGHT)), | ||
| ) | ||
|
|
||
| const recordTime = computed(() => { | ||
| const minutes = String(Math.floor(props.seconds / 60)).padStart(2, '0') | ||
|
|
@@ -24,14 +29,13 @@ const recordTime = computed(() => { | |
| </script> | ||
|
|
||
| <template> | ||
| <div class="mk-input-box mk-conversation-message-input border-primary/60! bg-primary/10 w-full shadow-lg"> | ||
| <div class="flex h-8 items-center justify-center gap-0.5"> | ||
| <span | ||
| v-for="(level, index) in waveLevels" | ||
| :key="index" | ||
| class="bg-primary w-0.5 rounded-full transition-[height] duration-100" | ||
| :style="{ height: `${Math.max(2, (level / 100) * WAVE_MAX_HEIGHT)}px` }" | ||
| /> | ||
| <div class="mk-input-box mk-conversation-message-input border-primary/60! bg-primary/10! w-full shadow-lg"> | ||
| <div class="flex-align-center h-8 justify-center"> | ||
| <div class="flex-align-center w-full max-w-90 justify-between px-4"> | ||
| <template v-for="(height, index) in waveHeights" :key="index"> | ||
| <span class="bg-primary w-[2px] shrink-0 rounded-full transition-[height] duration-100 ease-out" :style="{ height: `${height}px` }" /> | ||
| </template> | ||
| </div> | ||
| </div> | ||
| <div class="mt-3 flex items-center justify-end"> | ||
| <span class="text-N400 mr-4">{{ recordTime }}</span> | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's my review of the code changes: Potential Issues1. Behavior change: waveform distribution
2. Height baseline inconsistency
3. Level clamping added — good 4. Style / Optimization Suggestions5. Power-of-two spacing 6. Redundant <template v-for="(height, index) in waveHeights" :key="index">The 7. Magic numbers in class string 8. Transition on 9. 10. Accessibility SummaryFunctionally sound, main concerns are layout robustness (variable bar count vs. |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here is a review of the provided Vue component code, highlighting irregularities, potential issues, and optimization suggestions.
1. Critical Irregularity: Missing Keyframes Name Consistency
While not technically an error if consistent, it is a best practice to ensure the animation name in
animationmatches exactly with@keyframes. Here they match (voice-wave), so this is fine, but note thatanimation-delaywithout negative values on some bars may look disjointed. Currently, bars 1 and 5 have no delay, bar 2/4 have-0.2s, and bar 3 has-0.4s. This creates a wave effect starting from the center outward, which is visually intuitive. No change needed here unless specific timing is desired for outer bars.2. Potential Issue: Percentage-Based Heights +
scaleYAnimation Conflict.voice-wave-icon__barelements have fixed percentage heights (e.g.,height: 40%,70%,100%) relative to the parent container. The@keyframes voice-waveanimatestransform: scaleY().scaleY(0.45)is applied to a bar already at40%height, it becomes~18%of the parent's height. WhenscaleY(1)is applied, it returns to its original CSS-defined height. This means the bars do not actually reach equal visual peaks unless their base heights are equal. The "wave" will look uneven because Bar 1 starts small and grows, Bar 3 starts tall and shrinks slightly then grows to full.transform-origin: center bottom;and animating fromscaleY(min)toscaleY(max)or set all bars toheight: 100%and usescaleYvariations as the base state. However, the current approach is common for simple "audio visualizer" icons. Just be aware the visual "peak" height varies per bar due to differing base sizes.3. Accessibility & Semantics
aria-hidden="true"is correctly used since this is a decorative icon.role="img"if this icon conveys meaningful status (e.g., "voice active"), though usually, such status is conveyed by surrounding text or other ARIA states. For now,aria-hiddenis appropriate.4. Optimization: Template Loop Syntax
<template v-for="barIndex in 5" :key="barIndex">:key="barIndex"is correct. However, you can simplify if you don't need the index variable explicitly beyond keying. It’s fine as-is.5. Code Style & Best Practices
A. Prop Validation Enhancements
colorprop currently defaults toundefined. The template uses:style="{ ..., color }". Ifundefined, it might cause unintended inheritances or invalid CSS values depending on the browser. It’s safer to either:colorprop if inheritingcurrentColorfrom parent is intended.{ size: 24, color: 'currentColor' }.B. SCSS: Reduced Motion Fallback
heightvalues (40%, 70%, 100%). This is good—it preserves the visual shape without motion. However, ensure thetransformproperty isn’t stuck in a mid-animation state (it won’t be, since no animation is applied). This is correct.C. CSS Performance
current-colorusage is efficient.border-radius: 999pxensures pill shapes regardless of width/height changes—this is a robust trick.D. Gap Property
gap: 10%;insidedisplay: inline-flex;with percentage gaps can sometimes behave unexpectedly depending on the flex context and container sizing. Since the parent is an inline element with definedwidth/height, this should work, but test across browsers for consistent spacing. Pixel-based gaps orfrunits (if using grid) might be more predictable, but for simple icons,%is often acceptable.✅ Suggested Revised Code (Minor Tweaks)
Summary of Recommendations:
color: Preventsundefinedbeing passed into inline styles.transform-origin: Explicitly settingcenterensures scaling behaves predictably across browsers.@media (prefers-reduced-motion): While nesting it inside&__barworks in modern SCSS compilers supporting modern CSS specs, defining global utility classes or top-level media queries for accessibility rules is often clearer and safer for CSS extraction tools.height: 100%and varying only thescaleYstart/end points in keyframes (which would require different keyframes or CSS custom properties per bar for complex delays/scales).