|  | @@ -0,0 +1,204 @@
 | 
	
		
			
				|  |  | +interface CrunkerConstructorOptions {
 | 
	
		
			
				|  |  | +   sampleRate: number
 | 
	
		
			
				|  |  | +   concurrentNetworkRequests: number
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +type CrunkerInputTypes = string | File | Blob
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +export default class Crunker {
 | 
	
		
			
				|  |  | +   private readonly _sampleRate: number
 | 
	
		
			
				|  |  | +   private readonly _concurrentNetworkRequests: number
 | 
	
		
			
				|  |  | +   private readonly _context: AudioContext
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +   constructor({ sampleRate, concurrentNetworkRequests = 200 }: Partial<CrunkerConstructorOptions> = {}) {
 | 
	
		
			
				|  |  | +      this._context = this._createContext(sampleRate)
 | 
	
		
			
				|  |  | +      sampleRate ||= this._context.sampleRate
 | 
	
		
			
				|  |  | +      this._sampleRate = sampleRate
 | 
	
		
			
				|  |  | +      this._concurrentNetworkRequests = concurrentNetworkRequests
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _createContext(sampleRate = 44_100): AudioContext {
 | 
	
		
			
				|  |  | +      window.AudioContext = window.AudioContext || (window as any).webkitAudioContext || (window as any).mozAudioContext
 | 
	
		
			
				|  |  | +      return new AudioContext({ sampleRate })
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   /**
 | 
	
		
			
				|  |  | +    *转换url等类型为buffer
 | 
	
		
			
				|  |  | +    */
 | 
	
		
			
				|  |  | +   async fetchAudio(...filepaths: CrunkerInputTypes[]): Promise<AudioBuffer[]> {
 | 
	
		
			
				|  |  | +      const buffers: AudioBuffer[] = []
 | 
	
		
			
				|  |  | +      const groups = Math.ceil(filepaths.length / this._concurrentNetworkRequests)
 | 
	
		
			
				|  |  | +      for (let i = 0; i < groups; i++) {
 | 
	
		
			
				|  |  | +         const group = filepaths.slice(i * this._concurrentNetworkRequests, (i + 1) * this._concurrentNetworkRequests)
 | 
	
		
			
				|  |  | +         buffers.push(...(await this._fetchAudio(...group)))
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +      return buffers
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private async _fetchAudio(...filepaths: CrunkerInputTypes[]): Promise<AudioBuffer[]> {
 | 
	
		
			
				|  |  | +      return await Promise.all(
 | 
	
		
			
				|  |  | +         filepaths.map(async filepath => {
 | 
	
		
			
				|  |  | +            let buffer: ArrayBuffer
 | 
	
		
			
				|  |  | +            if (filepath instanceof File || filepath instanceof Blob) {
 | 
	
		
			
				|  |  | +               buffer = await filepath.arrayBuffer()
 | 
	
		
			
				|  |  | +            } else {
 | 
	
		
			
				|  |  | +               buffer = await fetch(filepath).then(response => {
 | 
	
		
			
				|  |  | +                  if (response.headers.has("Content-Type") && !response.headers.get("Content-Type")!.includes("audio/")) {
 | 
	
		
			
				|  |  | +                     console.warn(
 | 
	
		
			
				|  |  | +                        `Crunker: Attempted to fetch an audio file, but its MIME type is \`${
 | 
	
		
			
				|  |  | +                           response.headers.get("Content-Type")!.split(";")[0]
 | 
	
		
			
				|  |  | +                        }\`. We'll try and continue anyway. (file: "${filepath}")`
 | 
	
		
			
				|  |  | +                     )
 | 
	
		
			
				|  |  | +                  }
 | 
	
		
			
				|  |  | +                  return response.arrayBuffer()
 | 
	
		
			
				|  |  | +               })
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +            /* 这里有个坑 safa浏览器老一点的版本不支持decodeAudioData返回promise 所以用这种老式写法 */
 | 
	
		
			
				|  |  | +            return await new Promise((res, rej) => {
 | 
	
		
			
				|  |  | +               this._context.decodeAudioData(
 | 
	
		
			
				|  |  | +                  buffer,
 | 
	
		
			
				|  |  | +                  buffer => {
 | 
	
		
			
				|  |  | +                     res(buffer)
 | 
	
		
			
				|  |  | +                  },
 | 
	
		
			
				|  |  | +                  err => {
 | 
	
		
			
				|  |  | +                     rej(err)
 | 
	
		
			
				|  |  | +                  }
 | 
	
		
			
				|  |  | +               )
 | 
	
		
			
				|  |  | +            })
 | 
	
		
			
				|  |  | +         })
 | 
	
		
			
				|  |  | +      )
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   /**
 | 
	
		
			
				|  |  | +    * 根据时间合并音频
 | 
	
		
			
				|  |  | +    */
 | 
	
		
			
				|  |  | +   mergeAudioBuffers(buffers: AudioBuffer[], times: number[]): AudioBuffer {
 | 
	
		
			
				|  |  | +      if (buffers.length !== times.length) {
 | 
	
		
			
				|  |  | +         throw new Error("buffer数量和times数量必须一致")
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +      const output = this._context.createBuffer(this._maxNumberOfChannels(buffers), this._sampleRate * this._maxDuration(buffers), this._sampleRate)
 | 
	
		
			
				|  |  | +      buffers.forEach((buffer, index) => {
 | 
	
		
			
				|  |  | +         for (let channelNumber = 0; channelNumber < buffer.numberOfChannels; channelNumber++) {
 | 
	
		
			
				|  |  | +            const outputData = output.getChannelData(channelNumber)
 | 
	
		
			
				|  |  | +            const bufferData = buffer.getChannelData(channelNumber)
 | 
	
		
			
				|  |  | +            const offsetNum = Math.round(times[index] * this._sampleRate) //时间偏差
 | 
	
		
			
				|  |  | +            for (let i = buffer.getChannelData(channelNumber).length - 1; i >= 0; i--) {
 | 
	
		
			
				|  |  | +               outputData[i + offsetNum] += bufferData[i]
 | 
	
		
			
				|  |  | +               // 当合并大于1或者小于-1的时候可能会爆音  所以这里取最大值和最小值
 | 
	
		
			
				|  |  | +               if (outputData[i + offsetNum] > 1) {
 | 
	
		
			
				|  |  | +                  outputData[i + offsetNum] = 1
 | 
	
		
			
				|  |  | +               }
 | 
	
		
			
				|  |  | +               if (outputData[i + offsetNum] < -1) {
 | 
	
		
			
				|  |  | +                  outputData[i + offsetNum] = -1
 | 
	
		
			
				|  |  | +               }
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +            output.getChannelData(channelNumber).set(outputData)
 | 
	
		
			
				|  |  | +         }
 | 
	
		
			
				|  |  | +      })
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +      return output
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   /**
 | 
	
		
			
				|  |  | +    * 根据buffer导出audio标签
 | 
	
		
			
				|  |  | +    */
 | 
	
		
			
				|  |  | +   exportAudioElement(buffer: AudioBuffer, type = "audio/mp3"): HTMLAudioElement {
 | 
	
		
			
				|  |  | +      const recorded = this._interleave(buffer)
 | 
	
		
			
				|  |  | +      const dataview = this._writeHeaders(recorded, buffer.numberOfChannels, buffer.sampleRate)
 | 
	
		
			
				|  |  | +      const audioBlob = new Blob([dataview], { type })
 | 
	
		
			
				|  |  | +      return this._renderAudioElement(audioBlob)
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   /**
 | 
	
		
			
				|  |  | +    * 计算音频前面的空白
 | 
	
		
			
				|  |  | +    */
 | 
	
		
			
				|  |  | +   calculateSilenceDuration(buffer: AudioBuffer) {
 | 
	
		
			
				|  |  | +      const threshold = 0.01 // 静音阈值,低于此值的部分认为是静音
 | 
	
		
			
				|  |  | +      const sampleRate = buffer.sampleRate
 | 
	
		
			
				|  |  | +      const channelData = buffer.getChannelData(0) // 只处理单声道数据
 | 
	
		
			
				|  |  | +      let silenceDuration = 0
 | 
	
		
			
				|  |  | +      for (let i = 0; i < channelData.length; i++) {
 | 
	
		
			
				|  |  | +         if (Math.abs(channelData[i]) > threshold) {
 | 
	
		
			
				|  |  | +            break
 | 
	
		
			
				|  |  | +         }
 | 
	
		
			
				|  |  | +         silenceDuration++
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +      // 将样本数转换为秒
 | 
	
		
			
				|  |  | +      silenceDuration = silenceDuration / sampleRate
 | 
	
		
			
				|  |  | +      return silenceDuration
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   /**
 | 
	
		
			
				|  |  | +    * buffer 转为 blob
 | 
	
		
			
				|  |  | +    */
 | 
	
		
			
				|  |  | +   audioBuffToBlob(buffer: AudioBuffer, type = "audio/mp3") {
 | 
	
		
			
				|  |  | +      const recorded = this._interleave(buffer)
 | 
	
		
			
				|  |  | +      const dataview = this._writeHeaders(recorded, buffer.numberOfChannels, buffer.sampleRate)
 | 
	
		
			
				|  |  | +      return new Blob([dataview], { type })
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _maxNumberOfChannels(buffers: AudioBuffer[]): number {
 | 
	
		
			
				|  |  | +      return Math.max(...buffers.map(buffer => buffer.numberOfChannels))
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _maxDuration(buffers: AudioBuffer[]): number {
 | 
	
		
			
				|  |  | +      return Math.max(...buffers.map(buffer => buffer.duration))
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _interleave(input: AudioBuffer): Float32Array {
 | 
	
		
			
				|  |  | +      if (input.numberOfChannels === 1) {
 | 
	
		
			
				|  |  | +         return input.getChannelData(0)
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +      const channels = []
 | 
	
		
			
				|  |  | +      for (let i = 0; i < input.numberOfChannels; i++) {
 | 
	
		
			
				|  |  | +         channels.push(input.getChannelData(i))
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +      const length = channels.reduce((prev, channelData) => prev + channelData.length, 0)
 | 
	
		
			
				|  |  | +      const result = new Float32Array(length)
 | 
	
		
			
				|  |  | +      let index = 0
 | 
	
		
			
				|  |  | +      let inputIndex = 0
 | 
	
		
			
				|  |  | +      while (index < length) {
 | 
	
		
			
				|  |  | +         channels.forEach(channelData => {
 | 
	
		
			
				|  |  | +            result[index++] = channelData[inputIndex]
 | 
	
		
			
				|  |  | +         })
 | 
	
		
			
				|  |  | +         inputIndex++
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +      return result
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _renderAudioElement(blob: Blob): HTMLAudioElement {
 | 
	
		
			
				|  |  | +      const audio = document.createElement("audio")
 | 
	
		
			
				|  |  | +      audio.src = this._renderURL(blob)
 | 
	
		
			
				|  |  | +      audio.load()
 | 
	
		
			
				|  |  | +      return audio
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _renderURL(blob: Blob): string {
 | 
	
		
			
				|  |  | +      return (window.URL || window.webkitURL).createObjectURL(blob)
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _writeHeaders(buffer: Float32Array, numOfChannels: number, sampleRate: number): DataView {
 | 
	
		
			
				|  |  | +      const bitDepth = 16
 | 
	
		
			
				|  |  | +      const bytesPerSample = bitDepth / 8
 | 
	
		
			
				|  |  | +      const sampleSize = numOfChannels * bytesPerSample
 | 
	
		
			
				|  |  | +      const fileHeaderSize = 8
 | 
	
		
			
				|  |  | +      const chunkHeaderSize = 36
 | 
	
		
			
				|  |  | +      const chunkDataSize = buffer.length * bytesPerSample
 | 
	
		
			
				|  |  | +      const chunkTotalSize = chunkHeaderSize + chunkDataSize
 | 
	
		
			
				|  |  | +      const arrayBuffer = new ArrayBuffer(fileHeaderSize + chunkTotalSize)
 | 
	
		
			
				|  |  | +      const view = new DataView(arrayBuffer)
 | 
	
		
			
				|  |  | +      this._writeString(view, 0, "RIFF")
 | 
	
		
			
				|  |  | +      view.setUint32(4, chunkTotalSize, true)
 | 
	
		
			
				|  |  | +      this._writeString(view, 8, "WAVE")
 | 
	
		
			
				|  |  | +      this._writeString(view, 12, "fmt ")
 | 
	
		
			
				|  |  | +      view.setUint32(16, 16, true)
 | 
	
		
			
				|  |  | +      view.setUint16(20, 1, true)
 | 
	
		
			
				|  |  | +      view.setUint16(22, numOfChannels, true)
 | 
	
		
			
				|  |  | +      view.setUint32(24, sampleRate, true)
 | 
	
		
			
				|  |  | +      view.setUint32(28, sampleRate * sampleSize, true)
 | 
	
		
			
				|  |  | +      view.setUint16(32, sampleSize, true)
 | 
	
		
			
				|  |  | +      view.setUint16(34, bitDepth, true)
 | 
	
		
			
				|  |  | +      this._writeString(view, 36, "data")
 | 
	
		
			
				|  |  | +      view.setUint32(40, chunkDataSize, true)
 | 
	
		
			
				|  |  | +      return this._floatTo16BitPCM(view, buffer, fileHeaderSize + chunkHeaderSize)
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _floatTo16BitPCM(dataview: DataView, buffer: Float32Array, offset: number): DataView {
 | 
	
		
			
				|  |  | +      for (let i = 0; i < buffer.length; i++, offset += 2) {
 | 
	
		
			
				|  |  | +         const tmp = Math.max(-1, Math.min(1, buffer[i]))
 | 
	
		
			
				|  |  | +         dataview.setInt16(offset, tmp < 0 ? tmp * 0x8000 : tmp * 0x7fff, true)
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +      return dataview
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +   private _writeString(dataview: DataView, offset: number, header: string): void {
 | 
	
		
			
				|  |  | +      for (let i = 0; i < header.length; i++) {
 | 
	
		
			
				|  |  | +         dataview.setUint8(offset + i, header.charCodeAt(i))
 | 
	
		
			
				|  |  | +      }
 | 
	
		
			
				|  |  | +   }
 | 
	
		
			
				|  |  | +}
 |