下载压缩文件,解压后找到ecg文件,并使用wavesurfer.js绘制心电图波形

下载压缩文件,解压后找到ecg文件,并使用wavesurfer.js绘制心电图波形。

这段代码是一个 Vue 组件,用于在网页中展示心电图(ECG)波形。它使用了 WaveSurfer.js 进行波形渲染,并通过解析一个 ZIP 压缩包中的 .ecg 文件来加载原始 ECG 数据。整体流程涉及 网络请求 → 解压 ZIP → 解析二进制 ECG 数据 → 上采样 → 转换为 WAV → 播放波形。
在这里插入图片描述

一、源码:

<template>
  <div>
    <div>
      <div ref="waveform" class="waveform"></div>
      <div v-if="loading" class="loading-indicator">
        音频加载中... {{ progress }}%
        <div class="progress-bar">
          <div class="progress-fill" :style="{ width: progress + '%' }"></div>
        </div>
      </div>
      <div v-if="error" class="error-message">{{ error }}</div>
    </div>

    <div style="display: inline">
      <span class="zoom-tips">Tips:鼠标滚动时,可以放大或缩小波形; </span>
      <span v-if="zoomLevel > 0"> 当前缩放级别:{{ zoomLevel }}(px/s); </span>
    </div>

    <div style="display: inline; margin-left: 20px">
      <!-- <a-button type="dashed" icon="save" @click="downloadAudio" :disabled="loading">下载音频</a-button> -->
      <a slot="tabBarExtraContent" v-if="zipUrl" :href="zipUrl" class="ant-btn ant-btn-primary" download>
        下载音频压缩包
      </a>
    </div>
  </div>
</template>

<script>
import Vue from 'vue'
import { ROLE_ID } from '@/store/mutation-types'
import WaveSurfer from 'wavesurfer.js'
import TimelinePlugin from 'wavesurfer.js/dist/plugins/timeline.esm.js'
import ZoomPlugin from 'wavesurfer.js/dist/plugins/zoom.esm.js'
import JSZip from 'jszip'

export default {
  name: 'SingleEcgWaveSurfer',
  props: {
    zipUrl: String, // 总音频文件URL
    status: Number,
  },
  data() {
    return {
      wavesurfer: null,
      timelinePlugin: null,
      zoomPlugin: null,
      loading: false,
      error: null,
      progress: 0,
      zoomLevel: 120, //默认1秒显示的像素
      canRegion: false,
      ecgMetadata: null,
    }
  },
  watch: {
    zipUrl: {
      async handler(newVal) {
        console.log('zipUrl=', newVal)
        // await this.downloadAndProcessZip(newVal)
      },
    },
  },
  async mounted() {
    var roleId = Vue.ls.get(ROLE_ID)
    if ((roleId == 11 || roleId == 15) && this.status < 300) {
      this.canRegion = true
    } else {
      this.canRegion = false
    }
    console.log(
      'SingleEcgWaveSurfer',
      'zipUrl=',
      this.zipUrl,
      'roleId=',
      roleId
    )
    await this.initWaveSurfer()
    if (this.zipUrl) {
      await this.downloadAndProcessZip(this.zipUrl)
    }
  },
  beforeDestroy() {
    this.cleanup()
  },
  methods: {
    async initWaveSurfer() {
      console.log('初始化 WaveSurfer', this.zoomLevel)
      // 初始化波形和频谱图
      this.wavesurfer = WaveSurfer.create({
        container: this.$refs.waveform,
        waveColor: '#48a1e0',
        progressColor: '#48e65dff',
        cursorColor: '#fff',
        height: 200,
        mediaControls: true,

        // 自定义渲染函数(适配Int16数据范围)
        renderFunction: (channels, ctx) => {
          const width = ctx.canvas.width
          const height = ctx.canvas.height
          const centerY = height / 2
          const maxMillimeter = 5 * 5 // 与原有逻辑一致:25格
          const zoom = height / maxMillimeter
          const gain = this.gain || 10 // 可从 data 或 props 获取动态值

          // 清除画布并绘制网格背景
          ctx.clearRect(0, 0, width, height)

          // 绘制网格线
          const drawGrid = (ctx, step) => {
            ctx.strokeStyle = '#ccc'
            ctx.lineWidth = 1
            const rowSpace = (height / maxMillimeter) * step

            // 垂直线(时间轴方向)
            for (let x = 0; x * rowSpace <= width; x++) {
              ctx.beginPath()
              ctx.moveTo(x * rowSpace, 0)
              ctx.lineTo(x * rowSpace, height)
              ctx.stroke()
            }

            // 水平线
            for (let y = 0; y <= maxMillimeter; y += step) {
              const yPos = y * (height / maxMillimeter)
              ctx.beginPath()
              ctx.moveTo(0, yPos)
              ctx.lineTo(width, yPos)
              ctx.stroke()
            }
          }

          // 绘制粗线(每5mm)
          ctx.lineWidth = 2
          drawGrid(ctx, 5)

          // 获取第一个声道数据
          if (!channels || channels.length === 0) return
          const int16Data = channels[0] // 单声道数据(Int16Array)

          // 绘制波形
          ctx.beginPath()
          ctx.strokeStyle = '#48a1e0'
          ctx.lineWidth = 1

          let lastX = null
          const dataSize = int16Data.length
          // 每20个点取一个绘制(优化性能)
          for (let i = 0; i < dataSize; i += 20) {
            // 转换Int16值到波形坐标(范围映射:-32768~32767 → 0~height)
            const value = int16Data[i] // 它已经是归一化后的信号,实际上是 -1 ~ +1 的浮点数
            // if (i < 100) {
            //   console.log('绘图ad值:', value)
            // }

            let y = Math.floor(centerY - value * gain * zoom)
            y = Math.max(0, Math.min(y, height)) // 钳位在画布范围内

            const x = (i / dataSize) * width

            if (lastX === null) {
              ctx.moveTo(x, y)
            } else {
              ctx.lineTo(x, y)
            }

            lastX = x
          }

          ctx.stroke()
        },
      })

      // 监听用户交互事件
      this.wavesurfer.on('interaction', () => {
        this.isUserInteraction = true
      })

      // 监听波形图进度变化
      this.wavesurfer.on('timeupdate', (currentTime) => {
        if (this.isUserInteraction) {
          this.isUserInteraction = false // 重置标志
        }
      })

      // 音频加载完成后,强制频谱图重新绘制
      this.wavesurfer.on('ready', () => {
        if (this.timelinePlugin) {
          this.timelinePlugin.destroy()
        }
        if (this.zoomPlugin) {
          this.zoomPlugin.destroy()
        }

        this.wavesurfer.setTime(0)
        this.registerWavePlugin()
      })

      this.wavesurfer.on('zoom', (currentZoom) => {
        this.zoomLevel = Math.round(currentZoom)
      })

      this.wavesurfer.on('finish', () => {
        this.wavesurfer.setTime(0)
      })
    },
    async downloadAudio() {
      console.log('下载音频附件')
    },

    // 修改下载和处理方法(使用Int16Array存储原始数据)
    async downloadAndProcessZip(url) {
      console.log('📥 开始下载并处理 ZIP:', url)
      if (!url) {
        console.error('❌ zipUrl 不存在')
        return
      }
      try {
        const response = await fetch(url)
        if (!response.ok) throw new Error('下载失败')
        const zipData = await response.arrayBuffer()
        const zip = await JSZip.loadAsync(zipData)

        // 有文件头:
        const rawDataFile = zip.file(/raw_data\.ecg$/i)[0]
        if (!rawDataFile) throw new Error('未找到 raw_data.ecg 文件')

        const bufferData = await rawDataFile.async('arraybuffer')
        const parsedData = this.parseECGData(bufferData)
        const ecgData = parsedData.samples
        const sampleRate = parsedData.metadata.sampleRate

        if (!ecgData) {
          console.log('❌ ecgData is undefined')
          return
        }

        // 1. 解析12bit ECG数据(基于Int16原始值)
        const originalData = this.parse12BitECGData(ecgData)

        // 2. 上采样到3000Hz
        const targetRate = 3000
        const upsampledData = this.upsampleECGData(originalData, sampleRate, targetRate)

        // 3. 转换为16bit WAV格式
        const wavData = this.convertToWAV(upsampledData, targetRate)

        // 4. 创建Blob URL
        const blob = new Blob([wavData], { type: 'audio/wav' })
        this.wavesurfer.loadBlob(blob)
      } catch (error) {
        console.error('❌ 处理ECG数据出错:', error.message)
        this.error = '加载音频失败: ' + error.message
      }
    },
    parseECGData(arrayBuffer) {
      // 数据验证
      if (!arrayBuffer || arrayBuffer.byteLength < 56) {
        console.error('无效的ECG数据: 数据长度不足')
        return null
      }
      if (!(arrayBuffer instanceof ArrayBuffer)) {
        console.error('Invalid argument: Expected ArrayBuffer')
        return null
      }

      const dataView = new DataView(arrayBuffer)
      let offset = 0

      // 1. 解析文件头信息
      const headerLength = dataView.getUint16(offset, true) // 小端格式
      offset += 2

      // UUID (36字节)
      const uuidBytes = new Uint8Array(arrayBuffer, offset, 36)
      const uuid = String.fromCharCode.apply(null, uuidBytes)
      offset += 36

      // 采样率 (2字节)
      const sampleRate = dataView.getUint16(offset, true)
      offset += 2

      // 开始时间 (16字节)
      const startTimeBytes = new Uint8Array(arrayBuffer, offset, 16)
      const startTime = String.fromCharCode.apply(null, startTimeBytes)
      offset += 16

      // 患者姓名 (剩余头长度)
      const nameBytes = new Uint8Array(arrayBuffer, offset, headerLength - offset)
      const patientName = this.gb2312ToString(nameBytes)

      // 2.  使用 DataView 安全读取非对齐的 16-bit 数据
      const dataStart = headerLength
      const dataLength = arrayBuffer.byteLength - dataStart

      // 创建用于存储采样的 Uint16Array
      const samples = new Uint16Array(dataLength / 2) // 总共 dataLength/2 个采样点

      for (let i = 0; i < samples.length; i++) {
        // 从 dataStart 开始,每 2 字节读一个 uint16(小端)
        samples[i] = dataView.getUint16(dataStart + i * 2, true) // true = little-endian
      }

      return {
        metadata: {
          headerLength,
          uuid,
          sampleRate,
          startTime,
          patientName,
        },
        samples: samples,
        duration: samples.length / sampleRate,
      }
    },

    // GB2312解码函数
    gb2312ToString(bytes) {
      try {
        const decoder = new TextDecoder('gb2312')
        return decoder.decode(bytes)
      } catch (error) {
        console.error('GB2312解码失败,尝试UTF-8:', error)
        try {
          const decoder = new TextDecoder('utf-8')
          return decoder.decode(bytes)
        } catch (e) {
          let result = ''
          for (let i = 0; i < bytes.length; i++) {
            if (bytes[i] !== 0) {
              result += String.fromCharCode(bytes[i])
            }
          }
          return result
        }
      }
    },

    // 解析12bit ECG数据(基于Int16原始值)
    parse12BitECGData(int16Data) {
      const sampleCount = int16Data.length
      const result = new Float32Array(sampleCount) // 存储以基准线为中心的数值
      const baseline = 2048 // 原始心电数据的基准线

      for (let i = 0; i < sampleCount; i++) {
        var adValue = int16Data[i] // 直接返回 0~4096 的原始 AD 值

        // 减去基准线,让数据围绕0波动(恢复心电信号的物理意义)
        const offsetValue = adValue - baseline // 范围:-2048 ~ 2047
        result[i] = offsetValue // ✅ 返回以0为中心的值
      }

      return result
    },

    // 上采样 ECG 数据(双线性插值)
    upsampleECGData(originalData, originalRate, targetRate) {
      const ratio = targetRate / originalRate // 3000 / 250 = 12
      const upsampledLength = Math.floor(originalData.length * ratio)
      const upsampledData = new Float32Array(upsampledLength)

      for (let i = 0; i < upsampledLength; i++) {
        // 计算当前输出点对应原始数据中的位置(浮点)
        const floatIndex = i / ratio
        const lowIndex = Math.floor(floatIndex)
        const highIndex = Math.min(lowIndex + 1, originalData.length - 1)

        // 双线性插值
        const t = floatIndex - lowIndex
        const a = originalData[lowIndex]
        const b = originalData[highIndex]
        upsampledData[i] = a * (1 - t) + b * t // 线性插值
      }

      return upsampledData
    },

    // WAV转换方法 convertToWAV 中正确映射 0~4096 → 0~32767(或中心化到 -32768~32767)
    convertToWAV(pcmData, sampleRate) {
      const buffer = new ArrayBuffer(44 + pcmData.length * 2)
      const view = new DataView(buffer)
      const bitsPerSample = 16
      const channels = 1

      // --- 写 WAV 头 ---
      this.writeString(view, 0, 'RIFF')
      view.setUint32(4, 36 + pcmData.length * 2, true)
      this.writeString(view, 8, 'WAVE')
      this.writeString(view, 12, 'fmt ')
      view.setUint32(16, 16, true)
      view.setUint16(20, 1, true)
      view.setUint16(22, channels, true)
      view.setUint32(24, sampleRate, true)
      view.setUint32(28, sampleRate * channels * (bitsPerSample / 8), true)
      view.setUint16(32, channels * (bitsPerSample / 8), true)
      view.setUint16(34, bitsPerSample, true)
      this.writeString(view, 36, 'data')
      view.setUint32(40, pcmData.length * 2, true)

      // --- 数据填充 ---
      let offset = 44
      for (let i = 0; i < pcmData.length; i++) {
        const centeredValue = pcmData[i] // 现在是 -2048 ~ 2048

        // 映射到 -32768 ~ 32767
        // 公式:sample = centeredValue * (32768 / 2048) = centeredValue * 16
        let sample = Math.floor(centeredValue * 16)
        sample = Math.max(-32768, Math.min(32767, sample))

        view.setInt16(offset + i * 2, sample, true)
      }

      return buffer
    },

    // 辅助函数:写入字符串到DataView
    writeString(view, offset, string) {
      for (let i = 0; i < string.length; i++) {
        view.setUint8(offset + i, string.charCodeAt(i))
      }
    },

    registerWavePlugin() {
      this.timelinePlugin = this.wavesurfer.registerPlugin(
        TimelinePlugin.create({
          height: 15,
          timeInterval: 0.1,
          primaryLabelInterval: 2,
          secondaryLabelInterval: 1,
          style: {
            fontSize: '12px',
            color: '#666',
          },
        })
      )

      this.zoomPlugin = this.wavesurfer.registerPlugin(
        ZoomPlugin.create({
          scale: 0.05,
          maxZoom: 1000,
          minZoom: 10,
        })
      )

      this.wavesurfer.zoom(this.zoomLevel)
    },

    cleanup() {
      if (this.wavesurfer) {
        this.wavesurfer.destroy()
      }
    },
  },
}
</script>

<style scoped>
.zoom-tips {
  margin: 18px 0;
  font-size: 16px;
  color: #ff0000;

  span {
    color: #000;
  }
}
.waveform {
  width: 1100px;
}
.loading-indicator {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #333;
  background: rgba(255, 255, 255, 0.9);
  padding: 10px 20px;
  border-radius: 8px;
  font-size: 14px;
  text-align: center;
}

.progress-bar {
  width: 100%;
  height: 6px;
  background: #eee;
  margin-top: 8px;
  border-radius: 3px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background: #48a1e0;
  transition: width 0.2s;
}

.error-message {
  color: red;
  font-size: 14px;
  padding: 10px;
  background: #ffe5e5;
  border-radius: 4px;
}
</style>

二、核心流程图

[开始]
   ↓
加载组件(mounted)
   ↓
初始化 WaveSurfer 实例(initWaveSurfer)
   ↓
监听 zipUrl 变化(watch)
   ↓
触发 downloadAndProcessZip(zipUrl)
   ↓
     ┌──────────────────────┐
     ↓                      ↓
  fetch(zipUrl)       失败 → 显示错误信息
     ↓
  arrayBuffer ← 响应体
     ↓
  JSZip.loadAsync(arrayBuffer)
     ↓
  查找 raw_data.ecg 文件
     ↓
  读取为 ArrayBuffer
     ↓
  parseECGData(buffer) → 解析文件头 + 提取 Uint16 样本数据
     ↓
  parse12BitECGData(samples) → 减去基线 (2048),转为中心对称的 Float32Array
     ↓
  upsampleECGData(data, 250Hz → 3000Hz) → 线性插值上采样
     ↓
  convertToWAV(data, 3000) → 映射到 -32768~32767,打包成 WAV 二进制
     ↓
  创建 Blob URL 并 loadBlob 到 wavesurfer
     ↓
  WaveSurfer 渲染波形(调用 renderFunction 自定义绘图)
     ↓
  注册 TimelinePlugin 和 ZoomPlugin 插件
     ↓
用户可交互:滚动缩放、播放、下载

三、数据流 转换详解:

原始 ZIP 包
    ↓
raw_data.ecg (二进制文件)
    ↓
Uint16Array: [0, 4095] → 原始 AD 值(未归零)
    ↓
Float32Array: [-2048, +2047] → 减去基线,恢复真实电压波动
    ↓
上采样至 3000Hz → 提高时间分辨率,使波形更平滑
    ↓
映射为 int16: [-32768, +32767] → 转换为标准音频 PCM 格式
    ↓
封装成 WAV 文件 → 浏览器可识别的音频格式
    ↓
WaveSurfer.js 渲染 → 可视化心电波形

3.1、详细转换步骤与原理

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3.2、过程中遇到的问题:

1、.ecg 是自定义二进制格式,不是标准音频格式,需要按照文档解析;
2、wavesurfer.js 要求 采样率最低3000,否则报错,所以,心电数据需要上采样
The sample rate provided (250) is outside the range [3000, 768000];
3、自定义渲染函数(renderFunction()),采样率低于3000就不执行;
4、12bit 数据流的解析转换很容易报错;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

唐诺

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值