/ src / logic / audioProcessing.ts
audioProcessing.ts
  1  import { ref } from "vue";
  2  
  3  export interface EQFilter {
  4    type: BiquadFilterType;
  5    frequency: { value: number };
  6    Q?: { value: number };
  7    gain?: { value: number };
  8    enabled: boolean;
  9  }
 10  
 11  export interface EQPreset {
 12    mainOut: {
 13      gain: number;
 14      muted: boolean;
 15    };
 16    filters: {
 17      [key: string]: EQFilter;
 18    };
 19  }
 20  
 21  // State
 22  export const filters = ref<BiquadFilterNode[]>([]);
 23  export const activeFilters = ref<BiquadFilterNode[]>([]);
 24  export const lastFilter = ref<BiquadFilterNode | null>(null);
 25  
 26  // Add mute state tracking
 27  export const isMuted = ref(false);
 28  export let rememberedVolume = 1.0;
 29  
 30  export let gainNode: GainNode | null = null;
 31  export let source: MediaStreamAudioSourceNode;
 32  export let audioCtx: AudioContext;
 33  
 34  // Add a cache to store user-adjusted filter values
 35  const userAdjustedFilterValues = new Map<
 36    string,
 37    {
 38      frequency: number;
 39      Q?: number;
 40      gain?: number;
 41    }
 42  >();
 43  
 44  // Add this utility function at the top with other utility functions
 45  function bandwidthToQ(bandwidth: number): number {
 46    // Convert bandwidth (0-100) to Q (0.1-10)
 47    // Higher bandwidth = lower Q, Lower bandwidth = higher Q
 48    return 10.1 - bandwidth / 10;
 49  }
 50  
 51  // Setup audio context and capture
 52  export async function startCapture(
 53    preset: EQPreset
 54  ): Promise<MediaStream | null> {
 55    return new Promise((resolve) => {
 56      chrome.tabCapture.capture({ audio: true }, (stream) => {
 57        if (!stream) {
 58          resolve(null);
 59          return;
 60        }
 61  
 62        const AudioContext = window.AudioContext;
 63        audioCtx = new AudioContext();
 64        source = audioCtx.createMediaStreamSource(stream);
 65        gainNode = audioCtx.createGain();
 66        source.connect(gainNode);
 67  
 68        // Initialize mute state and volume
 69        if (preset?.mainOut) {
 70          isMuted.value = preset.mainOut.muted || false;
 71          rememberedVolume = preset.mainOut.gain || 1.0;
 72          gainNode.gain.value = isMuted.value ? 0 : rememberedVolume;
 73        }
 74  
 75        // Create and initialize filter nodes with correct initial values
 76        filters.value = Object.entries(preset.filters).map(
 77          ([type, filterData]) => {
 78            const filter = audioCtx.createBiquadFilter();
 79            filter.type = type as BiquadFilterType;
 80  
 81            // Set initial frequency
 82            filter.frequency.value = filterData.frequency.value;
 83  
 84            // Set Q value based on filter type
 85            if (filterData.Q) {
 86              switch (type) {
 87                case "bandpass":
 88                  filter.Q.value = Math.max(0.1, filterData.Q.value); // Q must be > 0
 89                  break;
 90                case "peaking":
 91                  filter.Q.value = filterData.Q.value || 2.0; // Default Q for peaking
 92                  break;
 93                case "lowpass":
 94                case "highpass":
 95                  filter.Q.value = filterData.Q.value || 0.707; // Butterworth response
 96                  break;
 97                default:
 98                  filter.Q.value = filterData.Q.value || 1.0;
 99              }
100            }
101  
102            // Set gain for filters that support it
103            if (
104              filterData.gain &&
105              (type === "lowshelf" || type === "highshelf" || type === "peaking")
106            ) {
107              filter.gain.value = filterData.gain.value;
108            }
109  
110            return filter;
111          }
112        );
113  
114        gainNode.connect(audioCtx.destination);
115        resolve(stream);
116      });
117    });
118  }
119  
120  // Update toggle mute function
121  export function toggleMute(muted: boolean, volume: number) {
122    if (!gainNode || !audioCtx) return;
123  
124    // Store mute state
125    isMuted.value = muted;
126  
127    if (muted) {
128      // Remember current volume but set gain to 0
129      rememberedVolume = volume;
130      gainNode.gain.value = 0;
131    } else {
132      // Restore remembered volume
133      gainNode.gain.value = rememberedVolume;
134    }
135  }
136  
137  // Update volume setting function
138  export function setVolume(volume: number) {
139    if (!gainNode) return;
140  
141    // Always store the requested volume
142    rememberedVolume = volume;
143  
144    // Only apply it if not muted
145    if (!isMuted.value) {
146      gainNode.gain.value = volume;
147    }
148    // If muted, we keep gain at 0 but remember the new volume for when unmuted
149  }
150  
151  export function updateFilterValue(
152    filterType: BiquadFilterType,
153    value: number,
154    secondaryValue?: number
155  ) {
156    const filter = filters.value.find((filter) => filter.type === filterType);
157    if (!filter) return;
158  
159    // Always update frequency
160    filter.frequency.value = value;
161  
162    // Store user adjusted values in our cache
163    let cachedValues = userAdjustedFilterValues.get(filterType) || {
164      frequency: value,
165    };
166    cachedValues.frequency = value;
167  
168    // Handle secondary parameter based on filter type
169    if (secondaryValue !== undefined) {
170      switch (filterType) {
171        case "bandpass":
172          // Convert bandwidth value to Q
173          filter.Q.value = bandwidthToQ(secondaryValue);
174          cachedValues.Q = secondaryValue; // Store bandwidth value
175          break;
176        case "lowpass":
177        case "highpass":
178          filter.Q.value = Math.max(0.1, secondaryValue); // Resonance control
179          cachedValues.Q = secondaryValue;
180          break;
181        case "peaking":
182        case "lowshelf":
183        case "highshelf":
184          filter.gain.value = secondaryValue; // Gain control in dB
185          cachedValues.gain = secondaryValue;
186          break;
187      }
188    }
189  
190    // Update the cache
191    userAdjustedFilterValues.set(filterType, cachedValues);
192  }
193  
194  export function connectToFilter(
195    filterType: string,
196    enabled: boolean,
197    preset: EQPreset
198  ) {
199    const targetFilter = filters.value.find(
200      (filter) => filter.type === filterType
201    );
202    if (!targetFilter || !preset.filters) return;
203  
204    const filterData = preset.filters[filterType];
205    filterData.enabled = enabled;
206  
207    if (enabled) {
208      // Use cached user-adjusted values if available, otherwise use preset values
209      const cachedValues = userAdjustedFilterValues.get(filterType);
210  
211      if (cachedValues) {
212        // Restore user-adjusted values from cache
213        targetFilter.frequency.value = cachedValues.frequency;
214  
215        if (filterType === "bandpass" && cachedValues.Q !== undefined) {
216          targetFilter.Q.value = bandwidthToQ(cachedValues.Q);
217        } else if (cachedValues.Q !== undefined) {
218          targetFilter.Q.value = cachedValues.Q;
219        }
220  
221        if (
222          cachedValues.gain !== undefined &&
223          (filterType === "lowshelf" ||
224            filterType === "highshelf" ||
225            filterType === "peaking")
226        ) {
227          targetFilter.gain.value = cachedValues.gain;
228        }
229      } else {
230        // First-time enable, use preset values
231        targetFilter.frequency.value = filterData.frequency.value;
232  
233        // Handle Q and gain values properly
234        if (filterType === "bandpass" && filterData.Q) {
235          targetFilter.Q.value = bandwidthToQ(filterData.Q.value);
236        } else if (filterData.Q) {
237          targetFilter.Q.value = filterData.Q.value;
238        }
239  
240        if (
241          filterData.gain &&
242          (filterType === "lowshelf" ||
243            filterType === "highshelf" ||
244            filterType === "peaking")
245        ) {
246          targetFilter.gain.value = filterData.gain.value;
247        }
248  
249        // Initialize the cache with preset values
250        userAdjustedFilterValues.set(filterType, {
251          frequency: filterData.frequency.value,
252          Q: filterData.Q?.value,
253          gain: filterData.gain?.value,
254        });
255      }
256  
257      try {
258        if (activeFilters.value.length === 0) {
259          // First filter being added
260          source.disconnect();
261          source.connect(targetFilter);
262          targetFilter.connect(gainNode);
263          activeFilters.value.push(targetFilter);
264          lastFilter.value = targetFilter;
265        } else {
266          // Adding filter to chain
267          const lastActiveFilter =
268            activeFilters.value[activeFilters.value.length - 1];
269          if (lastActiveFilter) {
270            lastActiveFilter.disconnect();
271            lastActiveFilter.connect(targetFilter);
272            targetFilter.connect(gainNode);
273            activeFilters.value.push(targetFilter);
274            lastFilter.value = targetFilter;
275          }
276        }
277      } catch (e) {
278        console.warn("Error connecting filter:", e);
279      }
280    } else {
281      // Disabling filter
282      const filterIndex = activeFilters.value.findIndex(
283        (f) => f.type === filterType
284      );
285      if (filterIndex === -1) return;
286  
287      try {
288        if (activeFilters.value.length === 1) {
289          // Last filter being removed
290          targetFilter.disconnect();
291          source.disconnect();
292          source.connect(gainNode);
293          activeFilters.value = [];
294          lastFilter.value = null;
295        } else if (filterIndex === 0) {
296          // First filter in chain being removed
297          targetFilter.disconnect();
298          source.disconnect();
299          source.connect(activeFilters.value[1]);
300          activeFilters.value.splice(0, 1);
301          lastFilter.value = activeFilters.value[activeFilters.value.length - 1];
302        } else if (filterIndex === activeFilters.value.length - 1) {
303          // Last filter in chain being removed
304          const prevFilter = activeFilters.value[filterIndex - 1];
305          targetFilter.disconnect();
306          prevFilter.disconnect();
307          prevFilter.connect(gainNode);
308          activeFilters.value.splice(filterIndex, 1);
309          lastFilter.value = prevFilter;
310        } else {
311          // Middle filter being removed
312          const prevFilter = activeFilters.value[filterIndex - 1];
313          const nextFilter = activeFilters.value[filterIndex + 1];
314          targetFilter.disconnect();
315          prevFilter.disconnect();
316          prevFilter.connect(nextFilter);
317          activeFilters.value.splice(filterIndex, 1);
318          lastFilter.value = activeFilters.value[activeFilters.value.length - 1];
319        }
320      } catch (e) {
321        console.warn("Error disconnecting filter:", e);
322      }
323    }
324  }
325  
326  export function handleMonoToggle(isMono: boolean, currentGain: number) {
327    if (!source || !gainNode || !audioCtx) return false;
328  
329    const sourceNode = lastFilter.value || source;
330    sourceNode.disconnect();
331  
332    if (isMono) {
333      // Convert to mono
334      const splitter = audioCtx.createChannelSplitter(2);
335      const merger = audioCtx.createChannelMerger(2);
336  
337      sourceNode.connect(splitter);
338  
339      // Mix both channels equally for true mono
340      splitter.connect(merger, 0, 0); // Left to left
341      splitter.connect(merger, 1, 0); // Right to left
342      splitter.connect(merger, 0, 1); // Left to right
343      splitter.connect(merger, 1, 1); // Right to right
344  
345      // Each channel now gets 50% of each input
346      const gainCompensation = audioCtx.createGain();
347      gainCompensation.gain.value = 0.5;
348  
349      merger.connect(gainCompensation);
350      gainCompensation.connect(gainNode);
351    } else {
352      // Restore stereo
353      sourceNode.connect(gainNode);
354    }
355  
356    gainNode.gain.value = currentGain;
357    return !isMono;
358  }
359  
360  export function resetFilters() {
361    try {
362      source?.disconnect();
363      filters.value.forEach((filter) => filter.disconnect());
364      activeFilters.value = [];
365      lastFilter.value = null;
366      source?.connect(gainNode);
367    } catch (e) {
368      console.warn("Error resetting connections:", e);
369    }
370  }