Bin
2025-12-17 611bfe34c3c96199eaaf6cf9e41a75892e44e879
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
import { type AudioDecoderWorker, getAudioDecoderWorker } from "@humansignal/audio-file-decoder";
// eslint-disable-next-line
// @ts-ignore
import DecodeAudioWasm from "@humansignal/audio-file-decoder/decode-audio.wasm";
import { BaseAudioDecoder } from "./BaseAudioDecoder";
import { clamp, info } from "../Common/Utils";
import { SplitChannel } from "./SplitChannel";
 
const DURATION_CHUNK_SIZE = 60 * 30; // 30 minutes
 
export class AudioDecoder extends BaseAudioDecoder {
  private worker: AudioDecoderWorker | undefined;
 
  /**
   * Total number of chunks to decode.
   *
   * This is used to allocate the number of chunks to decode and to calculate the progress.
   * Influenced by the number of channels and the duration of the audio file, as this will cause errors
   * if the decoder tries to decode and return too much data at once.
   *
   * @example
   * 1hour 44.1kHz 2ch = 1 * 60 * 60 * 44100 * 2 = 158760000 samples -> 4 chunks (39690000 samples/chunk)
   * 1hour 44.1kHz 1ch = 1 * 60 * 60 * 44100 * 1 = 79380000 samples -> 2 chunks (39690000 samples/chunk)
   */
  getTotalChunks() {
    return Math.ceil((this._duration * this._channelCount) / DURATION_CHUNK_SIZE);
  }
 
  /**
   * Total size in duration seconds per chunk to decode.
   *
   * This is used to work out the number of samples to decode per chunk, as the decoder will
   * return an error if too much data is requested at once. This is influenced by the number of channels.
   */
  getChunkDuration() {
    return DURATION_CHUNK_SIZE / this._channelCount;
  }
 
  /**
   * Initialize the decoder if it has not already been initialized.
   */
  async init(arraybuffer: ArrayBuffer) {
    if (this.worker) return;
    this.worker = await getAudioDecoderWorker(DecodeAudioWasm, arraybuffer);
 
    info("decode:worker:ready", this.src);
  }
 
  /**
   * Decode the audio file in chunks to ensure the UI remains responsive.
   */
  async decode(options?: { multiChannel?: boolean }): Promise<void> {
    // If the worker has cached data we can skip the decode step
    if (this.sourceDecoded) {
      info("decode:cached", this.src);
      return;
    }
    if (this.sourceDecodeCancelled) {
      throw new Error("AudioDecoder: Worker decode cancelled and contains no data, did you call decoder.renew()?");
    }
    // The decoding process is already in progress, so wait for it to finish
    if (this.decodingPromise) {
      info("decode:inprogress", this.src);
      return this.decodingPromise;
    }
    if (!this.worker) throw new Error("AudioDecoder: Worker not initialized, did you call decoder.init()?");
 
    info("decode:start", this.src);
 
    // Generate a unique id for this decode operation
    this.decodeId = Date.now();
    // This is a shared promise which will be observed by all instances of the same source
    this.decodingPromise = new Promise((resolve) => (this.decodingResolve = resolve as any));
 
    let splitChannels: SplitChannel | undefined = undefined;
 
    try {
      // Set the worker instance and resolve the decoder promise
      this._channelCount = options?.multiChannel ? this.worker.channelCount : 1;
      this._sampleRate = this.worker.sampleRate;
      this._duration = this.worker.duration;
 
      let chunkIndex = 0;
      const totalChunks = this.getTotalChunks();
      const chunkIterator = this.chunkDecoder(options);
 
      splitChannels = this._channelCount > 1 ? new SplitChannel(this._channelCount) : undefined;
 
      const chunks = Array.from({ length: this._channelCount }).map(
        () => Array.from({ length: totalChunks }) as Float32Array[],
      );
 
      info("decode:chunk:start", this.src, chunkIndex, totalChunks);
 
      this.invoke("progress", [0, totalChunks]);
 
      // Work through the chunks of the file in a generator until done.
      // Allow this to be interrupted at any time safely.
      while (chunkIndex < totalChunks) {
        if (this.sourceDecodeCancelled) return;
 
        const result = chunkIterator.next();
 
        if (!result.done) {
          const value = await result.value;
 
          if (this.sourceDecodeCancelled) return;
 
          if (value) {
            // Only 1 channel, just copy the data of the chunk directly
            if (this._channelCount === 1) {
              chunks[0][chunkIndex] = value;
            } else {
              if (!splitChannels) throw new Error("AudioDecoder: splitChannels not initialized");
 
              // Multiple channels, split the data into separate channels within a web worker
              // This is done to avoid blocking the UI thread
              const channels = await splitChannels.split(value);
 
              if (this.sourceDecodeCancelled) return;
 
              channels.forEach((channel, index) => {
                chunks[index][chunkIndex] = channel;
              });
            }
          }
 
          this.invoke("progress", [chunkIndex + 1, totalChunks]);
 
          info("decode:chunk:process", this.src, chunkIndex, totalChunks);
 
          chunkIndex++;
        }
 
        if (result.done) {
          break;
        }
      }
 
      this.chunks = chunks;
 
      info("decode:complete", this.src);
    } finally {
      splitChannels?.destroy();
      this.dispose();
    }
  }
 
  /**
   * Web worker containing the ffmpeg wasm decoder must be disposed of to prevent memory leaks.
   */
  protected dispose() {
    if (this.worker) {
      this.worker.dispose();
      this.worker = undefined;
      info("decode:worker:disposed", this.src);
    }
 
    this.cleanupResolvers();
  }
 
  /**
   * Decode in chunks of up to 30 minutes until the whole file is decoded.
   * Do the work withing Web Worker to avoid blocking the UI.
   * Allow the work to be interrupted so that the worker can be disposed at any time safely.
   */
  private *chunkDecoder(options?: { multiChannel?: boolean }): Generator<Promise<Float32Array | null> | null> {
    if (!this.worker || this.sourceDecodeCancelled) return null;
 
    const totalDuration = this.worker.duration;
 
    // The duration start is offset by 1 second in the actual wasm decoder.
    // This is to ensure we correctly capture the entire data set for the waveform,
    // aligning it with the audio data when played back via the media element.
    // For whatever reason, this is not always an issue with all files (duration does not seem to be a factor here).
    // This does not negatively impact others which do not require this offset.
    let durationOffset = -1;
 
    while (true) {
      yield new Promise((resolve, reject) => {
        if (!this.worker || this.sourceDecodeCancelled) return resolve(null);
 
        const nextChunkDuration = clamp(totalDuration - durationOffset, 0, this.getChunkDuration());
        const currentOffset = durationOffset;
 
        durationOffset += nextChunkDuration;
 
        this.worker
          .decodeAudioData(currentOffset, nextChunkDuration, {
            multiChannel: options?.multiChannel ?? false,
            ...options,
          })
          .then(resolve)
          .catch(reject);
      });
    }
  }
}