@siteed/expo-audio-studio 2.12.3 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/android/build.gradle +11 -0
  3. package/android/src/main/AndroidManifest.xml +8 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +266 -42
  5. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +55 -1
  6. package/app.plugin.js +3 -1
  7. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  8. package/build/cjs/AudioDeviceManager.js +229 -40
  9. package/build/cjs/AudioDeviceManager.js.map +1 -1
  10. package/build/cjs/WebRecorder.web.js +1 -0
  11. package/build/cjs/WebRecorder.web.js.map +1 -1
  12. package/build/cjs/hooks/useAudioDevices.js +30 -5
  13. package/build/cjs/hooks/useAudioDevices.js.map +1 -1
  14. package/build/cjs/useAudioRecorder.js +53 -8
  15. package/build/cjs/useAudioRecorder.js.map +1 -1
  16. package/build/cjs/workers/InlineFeaturesExtractor.web.js +8 -2
  17. package/build/cjs/workers/InlineFeaturesExtractor.web.js.map +1 -1
  18. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  19. package/build/esm/AudioDeviceManager.js +229 -40
  20. package/build/esm/AudioDeviceManager.js.map +1 -1
  21. package/build/esm/WebRecorder.web.js +1 -0
  22. package/build/esm/WebRecorder.web.js.map +1 -1
  23. package/build/esm/hooks/useAudioDevices.js +31 -6
  24. package/build/esm/hooks/useAudioDevices.js.map +1 -1
  25. package/build/esm/useAudioRecorder.js +54 -9
  26. package/build/esm/useAudioRecorder.js.map +1 -1
  27. package/build/esm/workers/InlineFeaturesExtractor.web.js +8 -2
  28. package/build/esm/workers/InlineFeaturesExtractor.web.js.map +1 -1
  29. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +1 -0
  30. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  31. package/build/types/AudioDeviceManager.d.ts +82 -2
  32. package/build/types/AudioDeviceManager.d.ts.map +1 -1
  33. package/build/types/WebRecorder.web.d.ts.map +1 -1
  34. package/build/types/hooks/useAudioDevices.d.ts +1 -0
  35. package/build/types/hooks/useAudioDevices.d.ts.map +1 -1
  36. package/build/types/useAudioRecorder.d.ts.map +1 -1
  37. package/build/types/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  38. package/build/types/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  39. package/ios/AudioDeviceManager.swift +21 -9
  40. package/ios/ExpoAudioStreamModule.swift +33 -1
  41. package/package.json +7 -6
  42. package/plugin/build/index.cjs +194 -0
  43. package/plugin/build/index.d.cts +1 -0
  44. package/plugin/build/index.js +7 -6
  45. package/plugin/src/index.ts +8 -8
  46. package/src/AudioAnalysis/AudioAnalysis.types.ts +1 -0
  47. package/src/AudioDeviceManager.ts +290 -59
  48. package/src/WebRecorder.web.ts +1 -0
  49. package/src/hooks/useAudioDevices.ts +39 -6
  50. package/src/useAudioRecorder.tsx +103 -9
  51. package/src/workers/InlineFeaturesExtractor.web.tsx +8 -2
@@ -1,2 +1,2 @@
1
- export declare const InlineFeaturesExtractor = "\n// Constants\nconst N_FFT = 1024; // Default FFT size\nconst MAX_FFT_SIZE = 8192; // Maximum FFT size to prevent memory issues\nconst N_CHROMA = 12;\n\n// FFT Implementation with normalized Hann window\nfunction FFT(n) {\n this.n = n;\n this.cosTable = new Float32Array(n / 2);\n this.sinTable = new Float32Array(n / 2);\n this.hannWindow = new Float32Array(n);\n \n // Match Android implementation with precomputed tables\n const normalizationFactor = Math.sqrt(2.0 / n);\n for (var i = 0; i < n / 2; i++) {\n this.cosTable[i] = Math.cos(2.0 * Math.PI * i / n);\n this.sinTable[i] = Math.sin(2.0 * Math.PI * i / n);\n }\n \n // Precompute normalized Hann window to match Android\n for (var i = 0; i < n; i++) {\n this.hannWindow[i] = normalizationFactor * 0.5 * (1 - Math.cos(2.0 * Math.PI * i / (n - 1)));\n }\n}\n\nFFT.prototype.transform = function(data) {\n const n = data.length;\n \n // Validate input length is power of 2\n if ((n & (n - 1)) !== 0) {\n throw new Error('FFT length must be power of 2');\n }\n\n // Use iterative bit reversal instead of recursive\n const bitReversedIndices = new Uint32Array(n);\n for (let i = 0; i < n; i++) {\n let reversed = 0;\n let j = i;\n let bits = Math.log2(n);\n while (bits--) {\n reversed = (reversed << 1) | (j & 1);\n j >>= 1;\n }\n bitReversedIndices[i] = reversed;\n }\n\n // Apply bit reversal\n for (let i = 0; i < n; i++) {\n const j = bitReversedIndices[i];\n if (i < j) {\n const temp = data[i];\n data[i] = data[j];\n data[j] = temp;\n }\n }\n\n // Iterative FFT computation with optimized memory usage\n for (let step = 1; step < n; step <<= 1) {\n const jump = step << 1;\n const angleStep = Math.PI / step;\n\n for (let group = 0; group < n; group += jump) {\n for (let pair = group; pair < group + step; pair++) {\n const match = pair + step;\n const angle = angleStep * (pair - group);\n \n const currentCos = Math.cos(angle);\n const currentSin = Math.sin(angle);\n\n const real = currentCos * data[match] - currentSin * data[match + 1];\n const imag = currentCos * data[match + 1] + currentSin * data[match];\n\n data[match] = data[pair] - real;\n data[match + 1] = data[pair + 1] - imag;\n data[pair] += real;\n data[pair + 1] += imag;\n }\n }\n }\n};\n\n// Add realInverse method\nFFT.prototype.realInverse = function(powerSpectrum, output) {\n const n = powerSpectrum.length;\n const complexData = new Float32Array(n * 2);\n \n // Copy power spectrum to complex format\n for (let i = 0; i < n/2 + 1; i++) {\n complexData[2 * i] = powerSpectrum[i];\n if (2 * i + 1 < complexData.length) {\n complexData[2 * i + 1] = 0;\n }\n }\n \n // Conjugate for inverse FFT\n for (let i = 0; i < n; i++) {\n if (2 * i + 1 < complexData.length) {\n complexData[2 * i + 1] = -complexData[2 * i + 1];\n }\n }\n \n this.transform(complexData);\n \n // Copy real part to output and scale\n for (let i = 0; i < n; i++) {\n output[i] = complexData[2 * i] / n;\n }\n};\n\n// Add helper functions to match Android\nfunction nextPowerOfTwo(n) {\n let value = 1;\n while (value < n) {\n value *= 2;\n }\n return value;\n}\n\nfunction applyHannWindow(samples) {\n const output = new Float32Array(samples.length);\n for (let i = 0; i < samples.length; i++) {\n const multiplier = 0.5 * (1 - Math.cos(2 * Math.PI * i / (samples.length - 1)));\n output[i] = samples[i] * multiplier;\n }\n return output;\n}\n\n// Update spectral feature computation to match Android\nfunction computeSpectralFeatures(segment, sampleRate, featureOptions = {}) {\n try {\n // Early return if no spectral features are requested\n if (!featureOptions.spectralCentroid && \n !featureOptions.spectralFlatness && \n !featureOptions.spectralRollOff && \n !featureOptions.spectralBandwidth &&\n !featureOptions.magnitudeSpectrum) {\n return {\n centroid: 0,\n flatness: 0,\n rollOff: 0,\n bandwidth: 0,\n magnitudeSpectrum: []\n };\n }\n\n // Ensure we have valid data\n if (!segment || segment.length === 0) {\n throw new Error('Invalid segment data');\n }\n\n // Process in fixed-size chunks\n const chunkSize = N_FFT;\n const numChunks = Math.ceil(segment.length / chunkSize);\n \n let results = {\n centroid: 0,\n flatness: 0,\n rollOff: 0,\n bandwidth: 0,\n magnitudeSpectrum: new Float32Array(N_FFT / 2 + 1).fill(0)\n };\n \n let validChunks = 0;\n \n // Iterate through chunks\n for (let i = 0; i < numChunks; i++) {\n const start = i * chunkSize;\n const end = Math.min(start + chunkSize, segment.length);\n const chunk = segment.slice(start, end);\n \n if (chunk.length < N_FFT / 4) continue; // Skip very small chunks\n\n // Process the chunk\n const paddedChunk = new Float32Array(N_FFT);\n paddedChunk.set(applyHannWindow(chunk));\n\n const fft = new FFT(N_FFT);\n fft.transform(paddedChunk);\n\n // Calculate magnitude spectrum\n const chunkMagnitudeSpectrum = new Float32Array(N_FFT / 2 + 1);\n let hasSignal = false;\n \n for (let j = 0; j < N_FFT / 2; j++) {\n const re = paddedChunk[2 * j];\n const im = paddedChunk[2 * j + 1];\n const magnitude = Math.sqrt(re * re + im * im);\n chunkMagnitudeSpectrum[j] = magnitude;\n if (magnitude > Number.EPSILON) hasSignal = true;\n }\n \n if (!hasSignal) continue;\n validChunks++;\n\n // Accumulate results\n if (featureOptions.spectralCentroid) {\n const centroid = computeSpectralCentroid(chunkMagnitudeSpectrum, sampleRate);\n if (!isNaN(centroid)) results.centroid += centroid;\n }\n \n if (featureOptions.spectralFlatness) {\n const flatness = computeSpectralFlatness(chunkMagnitudeSpectrum);\n if (!isNaN(flatness)) results.flatness += flatness;\n }\n \n if (featureOptions.spectralRollOff) {\n const rolloff = computeSpectralRollOff(chunkMagnitudeSpectrum, sampleRate);\n if (!isNaN(rolloff)) results.rollOff += rolloff;\n }\n \n if (featureOptions.spectralBandwidth && !isNaN(results.centroid)) {\n const bandwidth = computeSpectralBandwidth(chunkMagnitudeSpectrum, sampleRate, results.centroid);\n if (!isNaN(bandwidth)) results.bandwidth += bandwidth;\n }\n \n if (featureOptions.magnitudeSpectrum) {\n for (let j = 0; j < results.magnitudeSpectrum.length; j++) {\n results.magnitudeSpectrum[j] += chunkMagnitudeSpectrum[j];\n }\n }\n }\n\n // Average the accumulated results\n if (validChunks > 0) {\n results.centroid /= validChunks;\n results.flatness /= validChunks;\n results.rollOff /= validChunks;\n results.bandwidth /= validChunks;\n \n if (featureOptions.magnitudeSpectrum) {\n for (let i = 0; i < results.magnitudeSpectrum.length; i++) {\n results.magnitudeSpectrum[i] /= validChunks;\n }\n }\n }\n\n return results;\n } catch (error) {\n console.error('[Worker] Spectral feature computation error:', error);\n return {\n centroid: 0,\n flatness: 0,\n rollOff: 0,\n bandwidth: 0,\n magnitudeSpectrum: []\n };\n }\n}\n\nfunction computeSpectralCentroid(magnitudeSpectrum, sampleRate) {\n const sum = magnitudeSpectrum.reduce((a, b) => a + (b || 0), 0);\n if (sum <= Number.EPSILON) return 0;\n \n const weightedSum = magnitudeSpectrum.reduce((acc, value, index) => \n acc + (index * (sampleRate / N_FFT) * (value || 0)), 0);\n \n return weightedSum / sum;\n}\n\nfunction computeSpectralFlatness(powerSpectrum) {\n // Add small epsilon to avoid log(0)\n const epsilon = Number.EPSILON;\n const validSpectrum = powerSpectrum.map(v => Math.max(v, epsilon));\n \n const geometricMean = Math.exp(\n validSpectrum\n .map(v => Math.log(v))\n .reduce((a, b) => a + b) / validSpectrum.length\n );\n \n const arithmeticMean =\n validSpectrum.reduce((a, b) => a + b) / validSpectrum.length;\n \n return geometricMean / arithmeticMean;\n}\n\nfunction computeSpectralRollOff(magnitudeSpectrum, sampleRate) {\n const totalEnergy = magnitudeSpectrum.reduce((a, b) => a + b, 0);\n const rollOffThreshold = totalEnergy * 0.85;\n let cumulativeEnergy = 0;\n\n for (let i = 0; i < magnitudeSpectrum.length; i++) {\n cumulativeEnergy += magnitudeSpectrum[i];\n if (cumulativeEnergy >= rollOffThreshold) {\n return (i / magnitudeSpectrum.length) * (sampleRate / 2);\n }\n }\n\n return 0;\n}\n\nfunction computeSpectralBandwidth(magnitudeSpectrum, sampleRate, centroid) {\n const sum = magnitudeSpectrum.reduce((a, b) => a + (b || 0), 0);\n if (sum <= Number.EPSILON) return 0;\n\n const weightedSum = magnitudeSpectrum.reduce(\n (acc, value, index) => {\n const freq = index * sampleRate / (2 * magnitudeSpectrum.length);\n return acc + (value || 0) * Math.pow(freq - centroid, 2);\n }, 0\n );\n\n return Math.sqrt(weightedSum / sum);\n}\n\nfunction computeChroma(segmentData, sampleRate) {\n // Ensure we have valid input data\n if (!segmentData || segmentData.length === 0) {\n return new Array(N_CHROMA).fill(0);\n }\n\n const fftLength = nextPowerOfTwo(Math.max(segmentData.length, N_FFT));\n const windowed = applyHannWindow(segmentData);\n const padded = new Float32Array(fftLength);\n padded.set(windowed.slice(0, Math.min(windowed.length, fftLength)));\n\n const fft = new FFT(fftLength);\n try {\n fft.transform(padded);\n } catch (e) {\n console.error('[Worker] FFT transform failed in chromagram:', e);\n return new Array(N_CHROMA).fill(0);\n }\n\n const chroma = new Float32Array(N_CHROMA).fill(0);\n const freqsPerBin = sampleRate / fftLength;\n let totalEnergy = 0;\n\n // First pass: compute magnitudes and total energy\n for (let i = 0; i < fftLength / 2; i++) {\n const freq = i * freqsPerBin;\n if (freq > 20) { // Only consider frequencies above 20 Hz\n const re = padded[2 * i];\n const im = padded[2 * i + 1] || 0;\n const magnitude = Math.sqrt(re * re + im * im);\n \n if (magnitude > Number.EPSILON) {\n // Use a more stable pitch class calculation\n const midiNote = 69 + 12 * Math.log2(freq / 440.0);\n const pitchClass = Math.round(midiNote) % 12;\n \n if (pitchClass >= 0 && pitchClass < 12) {\n chroma[pitchClass] += magnitude;\n totalEnergy += magnitude;\n }\n }\n }\n }\n\n // Normalize chroma values only if we have energy\n if (totalEnergy > Number.EPSILON) {\n for (let i = 0; i < N_CHROMA; i++) {\n chroma[i] = chroma[i] / totalEnergy;\n }\n }\n\n // Convert to regular array and ensure no NaN values\n return Array.from(chroma, v => isNaN(v) ? 0 : v);\n}\n\nfunction extractHNR(segmentData) {\n const frameSize = segmentData.length;\n const autocorrelation = new Float32Array(frameSize);\n\n // Compute the autocorrelation iteratively\n for (let i = 0; i < frameSize; i++) {\n let sum = 0;\n for (let j = 0; j < frameSize - i; j++) {\n sum += segmentData[j] * segmentData[j + i];\n }\n autocorrelation[i] = sum;\n }\n\n // Find the maximum autocorrelation value iteratively\n let maxAutocorrelation = -Infinity;\n for (let i = 1; i < autocorrelation.length; i++) {\n if (autocorrelation[i] > maxAutocorrelation) {\n maxAutocorrelation = autocorrelation[i];\n }\n }\n\n // Compute the HNR\n return autocorrelation[0] !== 0\n ? 10 * Math.log10(maxAutocorrelation / (autocorrelation[0] - maxAutocorrelation))\n : 0;\n}\n\nfunction estimatePitch(segment, sampleRate) {\n // Early validation\n if (!segment || segment.length < 2 || !sampleRate) return 0;\n\n try {\n // Apply Hann window\n const windowed = applyHannWindow(segment);\n\n // Pad for FFT\n const fftLength = nextPowerOfTwo(segment.length * 2);\n const padded = new Float32Array(fftLength);\n padded.set(windowed);\n\n // Perform FFT\n const fft = new FFT(fftLength);\n fft.transform(padded);\n\n // Compute power spectrum\n const powerSpectrum = new Float32Array(fftLength / 2 + 1);\n for (let i = 0; i <= fftLength / 2; i++) {\n const re = padded[2 * i];\n const im = padded[2 * i + 1] || 0;\n powerSpectrum[i] = re * re + im * im;\n }\n\n // Find peak frequency\n let maxPower = 0;\n let peakIndex = 0;\n const minFreq = 50; // Minimum frequency to consider (Hz)\n const maxFreq = 1000; // Maximum frequency to consider (Hz)\n const minBin = Math.floor(minFreq * fftLength / sampleRate);\n const maxBin = Math.ceil(maxFreq * fftLength / sampleRate);\n\n for (let i = minBin; i <= maxBin; i++) {\n if (powerSpectrum[i] > maxPower) {\n maxPower = powerSpectrum[i];\n peakIndex = i;\n }\n }\n\n // Convert peak index to frequency\n const fundamentalFreq = peakIndex * sampleRate / fftLength;\n\n // Return 0 if the detected frequency is outside reasonable bounds\n return (fundamentalFreq >= minFreq && fundamentalFreq <= maxFreq) ? \n fundamentalFreq : 0;\n\n } catch (error) {\n console.error('[Worker] Pitch estimation error:', error);\n return 0;\n }\n}\n\n// Unique ID counter - the only state we need to maintain\nlet uniqueIdCounter = 0\nlet lastEmitTime = Date.now()\n\nself.onmessage = function (event) {\n // Extract enableLogging early so we can use it consistently\n const enableLogging = event.data.enableLogging || false;\n \n // Create consistent logger that only logs when enabled\n const logger = enableLogging ? {\n debug: (...args) => console.debug('[Worker]', ...args),\n log: (...args) => console.log('[Worker]', ...args),\n warn: (...args) => console.warn('[Worker]', ...args),\n error: (...args) => console.error('[Worker]', ...args)\n } : {\n debug: () => {},\n log: () => {},\n warn: () => {},\n error: () => {}\n };\n \n // Check if this is a reset command\n if (event.data.command === 'resetCounter') {\n const newValue = event.data.value;\n logger.log('Reset counter request received with value:', newValue);\n \n // Always respect explicit resets through the resetCounter command\n uniqueIdCounter = typeof newValue === 'number' ? newValue : 0;\n logger.log('Counter explicitly set to:', uniqueIdCounter);\n \n return; // Exit early, don't process audio\n }\n\n // Regular audio processing\n const {\n channelData,\n sampleRate,\n segmentDurationMs,\n algorithm,\n bitDepth,\n fullAudioDurationMs,\n numberOfChannels,\n features: _features,\n intervalAnalysis = 500,\n } = event.data\n\n // Calculate subChunkStartTime safely, defaulting to 0 if fullAudioDurationMs is not a valid number\n const subChunkStartTime = (typeof fullAudioDurationMs === 'number' && !isNaN(fullAudioDurationMs) && fullAudioDurationMs >= 0)\n ? fullAudioDurationMs / 1000\n : 0;\n\n const features = _features || {}\n const bytesPerSample = bitDepth / 8; // Calculate bytes per sample\n\n const SILENCE_THRESHOLD = 0.01\n const MIN_SILENCE_DURATION = 1.5 * sampleRate // 1.5 seconds of silence\n const SPEECH_INERTIA_DURATION = 0.1 * sampleRate // Speech inertia duration in samples\n const RMS_THRESHOLD = 0.01\n const ZCR_THRESHOLD = 0.1\n\n // Placeholder functions for feature extraction\n const extractMFCC = (segmentData, sampleRate) => {\n // Implement MFCC extraction logic here\n return []\n }\n\n const extractSpectralCentroid = (segmentData, sampleRate) => {\n const magnitudeSpectrum = segmentData.map((v) => v * v)\n const sum = magnitudeSpectrum.reduce((a, b) => a + b, 0)\n if (sum === 0) return 0\n\n const weightedSum = magnitudeSpectrum.reduce(\n (acc, value, index) => acc + index * value,\n 0\n )\n return (\n ((weightedSum / sum) * (sampleRate / 2)) / magnitudeSpectrum.length\n )\n }\n\n const extractSpectralFlatness = (segmentData) => {\n const magnitudeSpectrum = segmentData.map((v) => Math.abs(v))\n const geometricMean = Math.exp(\n magnitudeSpectrum\n .map((v) => Math.log(v + Number.MIN_VALUE))\n .reduce((a, b) => a + b) / magnitudeSpectrum.length\n )\n const arithmeticMean =\n magnitudeSpectrum.reduce((a, b) => a + b) / magnitudeSpectrum.length\n return arithmeticMean === 0 ? 0 : geometricMean / arithmeticMean\n }\n\n const extractSpectralRollOff = (segmentData, sampleRate) => {\n const magnitudeSpectrum = segmentData.map((v) => Math.abs(v))\n const totalEnergy = magnitudeSpectrum.reduce((a, b) => a + b, 0)\n const rollOffThreshold = totalEnergy * 0.85\n let cumulativeEnergy = 0\n\n for (let i = 0; i < magnitudeSpectrum.length; i++) {\n cumulativeEnergy += magnitudeSpectrum[i]\n if (cumulativeEnergy >= rollOffThreshold) {\n return (i / magnitudeSpectrum.length) * (sampleRate / 2)\n }\n }\n\n return 0\n }\n\n const extractSpectralBandwidth = (segmentData, sampleRate) => {\n const centroid = extractSpectralCentroid(segmentData, sampleRate)\n const magnitudeSpectrum = segmentData.map((v) => Math.abs(v))\n const sum = magnitudeSpectrum.reduce((a, b) => a + b, 0)\n if (sum === 0) return 0\n\n const weightedSum = magnitudeSpectrum.reduce(\n (acc, value, index) => acc + value * Math.pow(index - centroid, 2),\n 0\n )\n return Math.sqrt(weightedSum / sum)\n }\n\n const extractChromagram = (segmentData, sampleRate) => {\n return [] // TODO implement\n }\n\n /**\n * Creates a features object based on requested features\n */\n function createFeaturesObject(\n features,\n maxAmp,\n rms,\n sumSquares,\n zeroCrossings,\n remainingSamples,\n spectralFeatures,\n channelData,\n startIdx,\n endIdx,\n sampleRate,\n numberOfChannels,\n bytesPerSample\n ) {\n // If no features are requested, return undefined\n if (!Object.values(features).some(function(v) { return v; })) {\n return undefined;\n }\n\n const result = {};\n \n if (features.energy) {\n result.energy = sumSquares;\n }\n if (features.rms) {\n result.rms = rms;\n }\n // Always include min/max amplitude if any features are requested\n result.minAmplitude = -maxAmp;\n result.maxAmplitude = maxAmp;\n \n if (features.zcr) {\n result.zcr = zeroCrossings / remainingSamples;\n }\n if (features.spectralCentroid) {\n result.spectralCentroid = spectralFeatures.centroid;\n }\n if (features.spectralFlatness) {\n result.spectralFlatness = spectralFeatures.flatness;\n }\n if (features.spectralRolloff) {\n result.spectralRolloff = spectralFeatures.rollOff;\n }\n if (features.spectralBandwidth) {\n result.spectralBandwidth = spectralFeatures.bandwidth;\n }\n if (features.chromagram) {\n result.chromagram = computeChroma(channelData.slice(startIdx, endIdx), sampleRate);\n }\n if (features.hnr) {\n result.hnr = extractHNR(channelData.slice(startIdx, endIdx));\n }\n if (features.pitch) {\n result.pitch = estimatePitch(channelData.slice(startIdx, endIdx), sampleRate);\n }\n \n return result;\n }\n\n function extractWaveform(\n channelData,\n sampleRate,\n segmentDurationMs,\n numberOfChannels,\n bytesPerSample\n ) {\n const logger = enableLogging ? {\n debug: (...args) => console.debug('[Worker]', ...args),\n log: (...args) => console.log('[Worker]', ...args),\n error: (...args) => console.error('[Worker]', ...args)\n } : {\n debug: () => {},\n log: () => {},\n error: () => {}\n }\n\n // Calculate amplitude range\n let min = Infinity\n let max = -Infinity\n for (let i = 0; i < channelData.length; i++) {\n min = Math.min(min, channelData[i])\n max = Math.max(max, channelData[i])\n }\n\n const totalSamples = channelData.length\n const durationMs = (totalSamples / sampleRate) * 1000\n \n // Calculate fixed segment sizes\n const samplesPerSegment = Math.floor(sampleRate * (segmentDurationMs / 1000));\n const numPoints = Math.floor(totalSamples / samplesPerSegment);\n const remainingSamples = totalSamples % samplesPerSegment;\n\n const dataPoints = []\n\n // Process full segments\n for (let i = 0; i < numPoints; i++) {\n const startIdx = i * samplesPerSegment\n const endIdx = startIdx + samplesPerSegment\n \n let sumSquares = 0\n let maxAmp = 0\n let zeroCrossings = 0\n\n // Calculate segment features\n for (let j = startIdx; j < endIdx; j++) {\n const value = channelData[j]\n sumSquares += value * value\n maxAmp = Math.max(maxAmp, Math.abs(value))\n if (j > 0 && value * channelData[j - 1] < 0) {\n zeroCrossings++\n }\n }\n\n const rms = Math.sqrt(sumSquares / samplesPerSegment)\n const startTime = subChunkStartTime + (startIdx / sampleRate)\n const endTime = subChunkStartTime + (endIdx / sampleRate)\n // Calculate byte positions correctly based on numberOfChannels and bytesPerSample\n const startPosition = startIdx * numberOfChannels * bytesPerSample\n const endPosition = endIdx * numberOfChannels * bytesPerSample\n\n var spectralFeatures = computeSpectralFeatures(channelData.slice(startIdx, endIdx), sampleRate, features);\n\n // Simply use the counter, increment after assigning\n const dataPoint = {\n id: uniqueIdCounter++,\n amplitude: maxAmp,\n rms,\n startTime,\n endTime,\n dB: 20 * Math.log10(rms + 1e-6),\n silent: rms < 0.01,\n startPosition,\n endPosition,\n samples: samplesPerSegment,\n }\n\n // Extract features if any are requested\n const extractedFeatures = createFeaturesObject(\n features,\n maxAmp,\n rms,\n sumSquares,\n zeroCrossings,\n samplesPerSegment,\n spectralFeatures,\n channelData,\n startIdx,\n endIdx,\n sampleRate,\n numberOfChannels,\n bytesPerSample\n );\n \n if (extractedFeatures) {\n dataPoint.features = extractedFeatures;\n }\n\n dataPoints.push(dataPoint)\n }\n\n // Handle remaining samples if they exist and are enough to process\n if (remainingSamples > samplesPerSegment / 4) { // Only process if we have at least 1/4 of a segment\n const startIdx = numPoints * samplesPerSegment\n const endIdx = totalSamples\n \n let sumSquares = 0\n let maxAmp = 0\n let zeroCrossings = 0\n\n for (let j = startIdx; j < endIdx; j++) {\n const value = channelData[j]\n sumSquares += value * value\n maxAmp = Math.max(maxAmp, Math.abs(value))\n if (j > 0 && value * channelData[j - 1] < 0) {\n zeroCrossings++\n }\n }\n\n const rms = Math.sqrt(sumSquares / remainingSamples)\n const startTime = subChunkStartTime + (startIdx / sampleRate);\n const endTime = subChunkStartTime + (endIdx / sampleRate);\n // Calculate byte positions correctly based on numberOfChannels and bytesPerSample\n const startPosition = startIdx * numberOfChannels * bytesPerSample\n const endPosition = endIdx * numberOfChannels * bytesPerSample\n\n var spectralFeatures = computeSpectralFeatures(channelData.slice(startIdx, endIdx), sampleRate, features);\n\n // Simply use the counter, increment after assigning\n const dataPoint = {\n id: uniqueIdCounter++,\n amplitude: maxAmp,\n rms,\n startTime,\n endTime,\n dB: 20 * Math.log10(rms + 1e-6),\n silent: rms < 0.01,\n startPosition,\n endPosition,\n samples: remainingSamples,\n }\n\n logger.debug('extractWaveform - dataPoint', dataPoint);\n // Extract features if any are requested\n const extractedFeatures = createFeaturesObject(\n features,\n maxAmp,\n rms,\n sumSquares,\n zeroCrossings,\n remainingSamples,\n spectralFeatures,\n channelData,\n startIdx,\n endIdx,\n sampleRate,\n numberOfChannels,\n bytesPerSample\n );\n \n if (extractedFeatures) {\n dataPoint.features = extractedFeatures;\n }\n\n dataPoints.push(dataPoint)\n }\n\n return {\n durationMs,\n dataPoints,\n amplitudeRange: { min, max },\n rmsRange: {\n min: 0,\n max: Math.max(Math.abs(min), Math.abs(max))\n },\n extractionTimeMs: Date.now() - lastEmitTime\n }\n }\n\n try {\n const result = extractWaveform(\n channelData,\n sampleRate,\n segmentDurationMs,\n numberOfChannels || 1, // Default to 1 channel if not provided\n bytesPerSample\n )\n\n // Send complete result immediately\n self.postMessage({\n command: 'features',\n result: {\n bitDepth,\n samples: channelData.length,\n numberOfChannels,\n sampleRate,\n segmentDurationMs,\n durationMs: result.durationMs,\n dataPoints: result.dataPoints,\n amplitudeRange: result.amplitudeRange,\n rmsRange: result.rmsRange,\n }\n })\n } catch (error) {\n console.error('[Worker] Error', {\n message: error.message,\n stack: error.stack\n });\n \n self.postMessage({ \n error: {\n message: error.message,\n stack: error.stack,\n name: error.name\n }\n });\n }\n}\n";
1
+ export declare const InlineFeaturesExtractor = "\n// Constants\nconst N_FFT = 1024; // Default FFT size\nconst MAX_FFT_SIZE = 8192; // Maximum FFT size to prevent memory issues\nconst N_CHROMA = 12;\n\n// FFT Implementation with normalized Hann window\nfunction FFT(n) {\n this.n = n;\n this.cosTable = new Float32Array(n / 2);\n this.sinTable = new Float32Array(n / 2);\n this.hannWindow = new Float32Array(n);\n \n // Match Android implementation with precomputed tables\n const normalizationFactor = Math.sqrt(2.0 / n);\n for (var i = 0; i < n / 2; i++) {\n this.cosTable[i] = Math.cos(2.0 * Math.PI * i / n);\n this.sinTable[i] = Math.sin(2.0 * Math.PI * i / n);\n }\n \n // Precompute normalized Hann window to match Android\n for (var i = 0; i < n; i++) {\n this.hannWindow[i] = normalizationFactor * 0.5 * (1 - Math.cos(2.0 * Math.PI * i / (n - 1)));\n }\n}\n\nFFT.prototype.transform = function(data) {\n const n = data.length;\n \n // Validate input length is power of 2\n if ((n & (n - 1)) !== 0) {\n throw new Error('FFT length must be power of 2');\n }\n\n // Use iterative bit reversal instead of recursive\n const bitReversedIndices = new Uint32Array(n);\n for (let i = 0; i < n; i++) {\n let reversed = 0;\n let j = i;\n let bits = Math.log2(n);\n while (bits--) {\n reversed = (reversed << 1) | (j & 1);\n j >>= 1;\n }\n bitReversedIndices[i] = reversed;\n }\n\n // Apply bit reversal\n for (let i = 0; i < n; i++) {\n const j = bitReversedIndices[i];\n if (i < j) {\n const temp = data[i];\n data[i] = data[j];\n data[j] = temp;\n }\n }\n\n // Iterative FFT computation with optimized memory usage\n for (let step = 1; step < n; step <<= 1) {\n const jump = step << 1;\n const angleStep = Math.PI / step;\n\n for (let group = 0; group < n; group += jump) {\n for (let pair = group; pair < group + step; pair++) {\n const match = pair + step;\n const angle = angleStep * (pair - group);\n \n const currentCos = Math.cos(angle);\n const currentSin = Math.sin(angle);\n\n const real = currentCos * data[match] - currentSin * data[match + 1];\n const imag = currentCos * data[match + 1] + currentSin * data[match];\n\n data[match] = data[pair] - real;\n data[match + 1] = data[pair + 1] - imag;\n data[pair] += real;\n data[pair + 1] += imag;\n }\n }\n }\n};\n\n// Add realInverse method\nFFT.prototype.realInverse = function(powerSpectrum, output) {\n const n = powerSpectrum.length;\n const complexData = new Float32Array(n * 2);\n \n // Copy power spectrum to complex format\n for (let i = 0; i < n/2 + 1; i++) {\n complexData[2 * i] = powerSpectrum[i];\n if (2 * i + 1 < complexData.length) {\n complexData[2 * i + 1] = 0;\n }\n }\n \n // Conjugate for inverse FFT\n for (let i = 0; i < n; i++) {\n if (2 * i + 1 < complexData.length) {\n complexData[2 * i + 1] = -complexData[2 * i + 1];\n }\n }\n \n this.transform(complexData);\n \n // Copy real part to output and scale\n for (let i = 0; i < n; i++) {\n output[i] = complexData[2 * i] / n;\n }\n};\n\n// Add helper functions to match Android\nfunction nextPowerOfTwo(n) {\n let value = 1;\n while (value < n) {\n value *= 2;\n }\n return value;\n}\n\nfunction applyHannWindow(samples) {\n const output = new Float32Array(samples.length);\n for (let i = 0; i < samples.length; i++) {\n const multiplier = 0.5 * (1 - Math.cos(2 * Math.PI * i / (samples.length - 1)));\n output[i] = samples[i] * multiplier;\n }\n return output;\n}\n\n// Update spectral feature computation to match Android\nfunction computeSpectralFeatures(segment, sampleRate, featureOptions = {}) {\n try {\n // Early return if no spectral features are requested\n if (!featureOptions.spectralCentroid && \n !featureOptions.spectralFlatness && \n !featureOptions.spectralRollOff && \n !featureOptions.spectralBandwidth &&\n !featureOptions.magnitudeSpectrum) {\n return {\n centroid: 0,\n flatness: 0,\n rollOff: 0,\n bandwidth: 0,\n magnitudeSpectrum: []\n };\n }\n\n // Ensure we have valid data\n if (!segment || segment.length === 0) {\n throw new Error('Invalid segment data');\n }\n\n // Process in fixed-size chunks\n const chunkSize = N_FFT;\n const numChunks = Math.ceil(segment.length / chunkSize);\n \n let results = {\n centroid: 0,\n flatness: 0,\n rollOff: 0,\n bandwidth: 0,\n magnitudeSpectrum: new Float32Array(N_FFT / 2 + 1).fill(0)\n };\n \n let validChunks = 0;\n \n // Iterate through chunks\n for (let i = 0; i < numChunks; i++) {\n const start = i * chunkSize;\n const end = Math.min(start + chunkSize, segment.length);\n const chunk = segment.slice(start, end);\n \n if (chunk.length < N_FFT / 4) continue; // Skip very small chunks\n\n // Process the chunk\n const paddedChunk = new Float32Array(N_FFT);\n paddedChunk.set(applyHannWindow(chunk));\n\n const fft = new FFT(N_FFT);\n fft.transform(paddedChunk);\n\n // Calculate magnitude spectrum\n const chunkMagnitudeSpectrum = new Float32Array(N_FFT / 2 + 1);\n let hasSignal = false;\n \n for (let j = 0; j < N_FFT / 2; j++) {\n const re = paddedChunk[2 * j];\n const im = paddedChunk[2 * j + 1];\n const magnitude = Math.sqrt(re * re + im * im);\n chunkMagnitudeSpectrum[j] = magnitude;\n if (magnitude > Number.EPSILON) hasSignal = true;\n }\n \n if (!hasSignal) continue;\n validChunks++;\n\n // Accumulate results\n if (featureOptions.spectralCentroid) {\n const centroid = computeSpectralCentroid(chunkMagnitudeSpectrum, sampleRate);\n if (!isNaN(centroid)) results.centroid += centroid;\n }\n \n if (featureOptions.spectralFlatness) {\n const flatness = computeSpectralFlatness(chunkMagnitudeSpectrum);\n if (!isNaN(flatness)) results.flatness += flatness;\n }\n \n if (featureOptions.spectralRollOff) {\n const rolloff = computeSpectralRollOff(chunkMagnitudeSpectrum, sampleRate);\n if (!isNaN(rolloff)) results.rollOff += rolloff;\n }\n \n if (featureOptions.spectralBandwidth && !isNaN(results.centroid)) {\n const bandwidth = computeSpectralBandwidth(chunkMagnitudeSpectrum, sampleRate, results.centroid);\n if (!isNaN(bandwidth)) results.bandwidth += bandwidth;\n }\n \n if (featureOptions.magnitudeSpectrum) {\n for (let j = 0; j < results.magnitudeSpectrum.length; j++) {\n results.magnitudeSpectrum[j] += chunkMagnitudeSpectrum[j];\n }\n }\n }\n\n // Average the accumulated results\n if (validChunks > 0) {\n results.centroid /= validChunks;\n results.flatness /= validChunks;\n results.rollOff /= validChunks;\n results.bandwidth /= validChunks;\n \n if (featureOptions.magnitudeSpectrum) {\n for (let i = 0; i < results.magnitudeSpectrum.length; i++) {\n results.magnitudeSpectrum[i] /= validChunks;\n }\n }\n }\n\n return results;\n } catch (error) {\n console.error('[Worker] Spectral feature computation error:', error);\n return {\n centroid: 0,\n flatness: 0,\n rollOff: 0,\n bandwidth: 0,\n magnitudeSpectrum: []\n };\n }\n}\n\nfunction computeSpectralCentroid(magnitudeSpectrum, sampleRate) {\n const sum = magnitudeSpectrum.reduce((a, b) => a + (b || 0), 0);\n if (sum <= Number.EPSILON) return 0;\n \n const weightedSum = magnitudeSpectrum.reduce((acc, value, index) => \n acc + (index * (sampleRate / N_FFT) * (value || 0)), 0);\n \n return weightedSum / sum;\n}\n\nfunction computeSpectralFlatness(powerSpectrum) {\n // Add small epsilon to avoid log(0)\n const epsilon = Number.EPSILON;\n const validSpectrum = powerSpectrum.map(v => Math.max(v, epsilon));\n \n const geometricMean = Math.exp(\n validSpectrum\n .map(v => Math.log(v))\n .reduce((a, b) => a + b) / validSpectrum.length\n );\n \n const arithmeticMean =\n validSpectrum.reduce((a, b) => a + b) / validSpectrum.length;\n \n return geometricMean / arithmeticMean;\n}\n\nfunction computeSpectralRollOff(magnitudeSpectrum, sampleRate) {\n const totalEnergy = magnitudeSpectrum.reduce((a, b) => a + b, 0);\n const rollOffThreshold = totalEnergy * 0.85;\n let cumulativeEnergy = 0;\n\n for (let i = 0; i < magnitudeSpectrum.length; i++) {\n cumulativeEnergy += magnitudeSpectrum[i];\n if (cumulativeEnergy >= rollOffThreshold) {\n return (i / magnitudeSpectrum.length) * (sampleRate / 2);\n }\n }\n\n return 0;\n}\n\nfunction computeSpectralBandwidth(magnitudeSpectrum, sampleRate, centroid) {\n const sum = magnitudeSpectrum.reduce((a, b) => a + (b || 0), 0);\n if (sum <= Number.EPSILON) return 0;\n\n const weightedSum = magnitudeSpectrum.reduce(\n (acc, value, index) => {\n const freq = index * sampleRate / (2 * magnitudeSpectrum.length);\n return acc + (value || 0) * Math.pow(freq - centroid, 2);\n }, 0\n );\n\n return Math.sqrt(weightedSum / sum);\n}\n\nfunction computeChroma(segmentData, sampleRate) {\n // Ensure we have valid input data\n if (!segmentData || segmentData.length === 0) {\n return new Array(N_CHROMA).fill(0);\n }\n\n const fftLength = nextPowerOfTwo(Math.max(segmentData.length, N_FFT));\n const windowed = applyHannWindow(segmentData);\n const padded = new Float32Array(fftLength);\n padded.set(windowed.slice(0, Math.min(windowed.length, fftLength)));\n\n const fft = new FFT(fftLength);\n try {\n fft.transform(padded);\n } catch (e) {\n console.error('[Worker] FFT transform failed in chromagram:', e);\n return new Array(N_CHROMA).fill(0);\n }\n\n const chroma = new Float32Array(N_CHROMA).fill(0);\n const freqsPerBin = sampleRate / fftLength;\n let totalEnergy = 0;\n\n // First pass: compute magnitudes and total energy\n for (let i = 0; i < fftLength / 2; i++) {\n const freq = i * freqsPerBin;\n if (freq > 20) { // Only consider frequencies above 20 Hz\n const re = padded[2 * i];\n const im = padded[2 * i + 1] || 0;\n const magnitude = Math.sqrt(re * re + im * im);\n \n if (magnitude > Number.EPSILON) {\n // Use a more stable pitch class calculation\n const midiNote = 69 + 12 * Math.log2(freq / 440.0);\n const pitchClass = Math.round(midiNote) % 12;\n \n if (pitchClass >= 0 && pitchClass < 12) {\n chroma[pitchClass] += magnitude;\n totalEnergy += magnitude;\n }\n }\n }\n }\n\n // Normalize chroma values only if we have energy\n if (totalEnergy > Number.EPSILON) {\n for (let i = 0; i < N_CHROMA; i++) {\n chroma[i] = chroma[i] / totalEnergy;\n }\n }\n\n // Convert to regular array and ensure no NaN values\n return Array.from(chroma, v => isNaN(v) ? 0 : v);\n}\n\nfunction extractHNR(segmentData) {\n const frameSize = segmentData.length;\n const autocorrelation = new Float32Array(frameSize);\n\n // Compute the autocorrelation iteratively\n for (let i = 0; i < frameSize; i++) {\n let sum = 0;\n for (let j = 0; j < frameSize - i; j++) {\n sum += segmentData[j] * segmentData[j + i];\n }\n autocorrelation[i] = sum;\n }\n\n // Find the maximum autocorrelation value iteratively\n let maxAutocorrelation = -Infinity;\n for (let i = 1; i < autocorrelation.length; i++) {\n if (autocorrelation[i] > maxAutocorrelation) {\n maxAutocorrelation = autocorrelation[i];\n }\n }\n\n // Compute the HNR\n return autocorrelation[0] !== 0\n ? 10 * Math.log10(maxAutocorrelation / (autocorrelation[0] - maxAutocorrelation))\n : 0;\n}\n\nfunction estimatePitch(segment, sampleRate) {\n // Early validation\n if (!segment || segment.length < 2 || !sampleRate) return 0;\n\n try {\n // Apply Hann window\n const windowed = applyHannWindow(segment);\n\n // Pad for FFT\n const fftLength = nextPowerOfTwo(segment.length * 2);\n const padded = new Float32Array(fftLength);\n padded.set(windowed);\n\n // Perform FFT\n const fft = new FFT(fftLength);\n fft.transform(padded);\n\n // Compute power spectrum\n const powerSpectrum = new Float32Array(fftLength / 2 + 1);\n for (let i = 0; i <= fftLength / 2; i++) {\n const re = padded[2 * i];\n const im = padded[2 * i + 1] || 0;\n powerSpectrum[i] = re * re + im * im;\n }\n\n // Find peak frequency\n let maxPower = 0;\n let peakIndex = 0;\n const minFreq = 50; // Minimum frequency to consider (Hz)\n const maxFreq = 1000; // Maximum frequency to consider (Hz)\n const minBin = Math.floor(minFreq * fftLength / sampleRate);\n const maxBin = Math.ceil(maxFreq * fftLength / sampleRate);\n\n for (let i = minBin; i <= maxBin; i++) {\n if (powerSpectrum[i] > maxPower) {\n maxPower = powerSpectrum[i];\n peakIndex = i;\n }\n }\n\n // Convert peak index to frequency\n const fundamentalFreq = peakIndex * sampleRate / fftLength;\n\n // Return 0 if the detected frequency is outside reasonable bounds\n return (fundamentalFreq >= minFreq && fundamentalFreq <= maxFreq) ? \n fundamentalFreq : 0;\n\n } catch (error) {\n console.error('[Worker] Pitch estimation error:', error);\n return 0;\n }\n}\n\n// Unique ID counter - the only state we need to maintain\nlet uniqueIdCounter = 0\nlet lastEmitTime = Date.now()\n\nself.onmessage = function (event) {\n // Extract enableLogging early so we can use it consistently\n const enableLogging = event.data.enableLogging || false;\n \n // Create consistent logger that only logs when enabled\n const logger = enableLogging ? {\n debug: (...args) => console.debug('[Worker]', ...args),\n log: (...args) => console.log('[Worker]', ...args),\n warn: (...args) => console.warn('[Worker]', ...args),\n error: (...args) => console.error('[Worker]', ...args)\n } : {\n debug: () => {},\n log: () => {},\n warn: () => {},\n error: () => {}\n };\n \n // Check if this is a reset command\n if (event.data.command === 'resetCounter') {\n const newValue = event.data.value;\n logger.log('Reset counter request received with value:', newValue);\n \n // Always respect explicit resets through the resetCounter command\n uniqueIdCounter = typeof newValue === 'number' ? newValue : 0;\n logger.log('Counter explicitly set to:', uniqueIdCounter);\n \n return; // Exit early, don't process audio\n }\n\n // Regular audio processing\n const {\n channelData,\n sampleRate,\n segmentDurationMs,\n algorithm,\n bitDepth,\n fullAudioDurationMs,\n numberOfChannels,\n features: _features,\n intervalAnalysis = 500,\n } = event.data\n\n // Calculate subChunkStartTime safely, defaulting to 0 if fullAudioDurationMs is not a valid number\n const subChunkStartTime = (typeof fullAudioDurationMs === 'number' && !isNaN(fullAudioDurationMs) && fullAudioDurationMs >= 0)\n ? fullAudioDurationMs / 1000\n : 0;\n\n const features = _features || {}\n const bytesPerSample = bitDepth / 8; // Calculate bytes per sample\n\n const SILENCE_THRESHOLD = 0.01\n const MIN_SILENCE_DURATION = 1.5 * sampleRate // 1.5 seconds of silence\n const SPEECH_INERTIA_DURATION = 0.1 * sampleRate // Speech inertia duration in samples\n const RMS_THRESHOLD = 0.01\n const ZCR_THRESHOLD = 0.1\n\n // Placeholder functions for feature extraction\n const extractMFCC = (segmentData, sampleRate) => {\n // Implement MFCC extraction logic here\n return []\n }\n\n const extractSpectralCentroid = (segmentData, sampleRate) => {\n const magnitudeSpectrum = segmentData.map((v) => v * v)\n const sum = magnitudeSpectrum.reduce((a, b) => a + b, 0)\n if (sum === 0) return 0\n\n const weightedSum = magnitudeSpectrum.reduce(\n (acc, value, index) => acc + index * value,\n 0\n )\n return (\n ((weightedSum / sum) * (sampleRate / 2)) / magnitudeSpectrum.length\n )\n }\n\n const extractSpectralFlatness = (segmentData) => {\n const magnitudeSpectrum = segmentData.map((v) => Math.abs(v))\n const geometricMean = Math.exp(\n magnitudeSpectrum\n .map((v) => Math.log(v + Number.MIN_VALUE))\n .reduce((a, b) => a + b) / magnitudeSpectrum.length\n )\n const arithmeticMean =\n magnitudeSpectrum.reduce((a, b) => a + b) / magnitudeSpectrum.length\n return arithmeticMean === 0 ? 0 : geometricMean / arithmeticMean\n }\n\n const extractSpectralRollOff = (segmentData, sampleRate) => {\n const magnitudeSpectrum = segmentData.map((v) => Math.abs(v))\n const totalEnergy = magnitudeSpectrum.reduce((a, b) => a + b, 0)\n const rollOffThreshold = totalEnergy * 0.85\n let cumulativeEnergy = 0\n\n for (let i = 0; i < magnitudeSpectrum.length; i++) {\n cumulativeEnergy += magnitudeSpectrum[i]\n if (cumulativeEnergy >= rollOffThreshold) {\n return (i / magnitudeSpectrum.length) * (sampleRate / 2)\n }\n }\n\n return 0\n }\n\n const extractSpectralBandwidth = (segmentData, sampleRate) => {\n const centroid = extractSpectralCentroid(segmentData, sampleRate)\n const magnitudeSpectrum = segmentData.map((v) => Math.abs(v))\n const sum = magnitudeSpectrum.reduce((a, b) => a + b, 0)\n if (sum === 0) return 0\n\n const weightedSum = magnitudeSpectrum.reduce(\n (acc, value, index) => acc + value * Math.pow(index - centroid, 2),\n 0\n )\n return Math.sqrt(weightedSum / sum)\n }\n\n const extractChromagram = (segmentData, sampleRate) => {\n return [] // TODO implement\n }\n\n /**\n * Creates a features object based on requested features\n */\n function createFeaturesObject(\n features,\n maxAmp,\n rms,\n sumSquares,\n zeroCrossings,\n remainingSamples,\n spectralFeatures,\n channelData,\n startIdx,\n endIdx,\n sampleRate,\n numberOfChannels,\n bytesPerSample\n ) {\n // If no features are requested, return undefined\n if (!Object.values(features).some(function(v) { return v; })) {\n return undefined;\n }\n\n const result = {};\n \n if (features.energy) {\n result.energy = sumSquares;\n }\n if (features.rms) {\n result.rms = rms;\n }\n // Always include min/max amplitude if any features are requested\n result.minAmplitude = -maxAmp;\n result.maxAmplitude = maxAmp;\n \n if (features.zcr) {\n result.zcr = zeroCrossings / remainingSamples;\n }\n if (features.spectralCentroid) {\n result.spectralCentroid = spectralFeatures.centroid;\n }\n if (features.spectralFlatness) {\n result.spectralFlatness = spectralFeatures.flatness;\n }\n if (features.spectralRolloff) {\n result.spectralRolloff = spectralFeatures.rollOff;\n }\n if (features.spectralBandwidth) {\n result.spectralBandwidth = spectralFeatures.bandwidth;\n }\n if (features.chromagram) {\n result.chromagram = computeChroma(channelData.slice(startIdx, endIdx), sampleRate);\n }\n if (features.hnr) {\n result.hnr = extractHNR(channelData.slice(startIdx, endIdx));\n }\n if (features.pitch) {\n result.pitch = estimatePitch(channelData.slice(startIdx, endIdx), sampleRate);\n }\n \n return result;\n }\n\n function extractWaveform(\n channelData,\n sampleRate,\n segmentDurationMs,\n numberOfChannels,\n bytesPerSample\n ) {\n const logger = enableLogging ? {\n debug: (...args) => console.debug('[Worker]', ...args),\n log: (...args) => console.log('[Worker]', ...args),\n error: (...args) => console.error('[Worker]', ...args)\n } : {\n debug: () => {},\n log: () => {},\n error: () => {}\n }\n\n // Calculate amplitude range\n let min = Infinity\n let max = -Infinity\n for (let i = 0; i < channelData.length; i++) {\n min = Math.min(min, channelData[i])\n max = Math.max(max, channelData[i])\n }\n\n const totalSamples = channelData.length\n const durationMs = (totalSamples / sampleRate) * 1000\n \n // Calculate fixed segment sizes\n const samplesPerSegment = Math.floor(sampleRate * (segmentDurationMs / 1000));\n const numPoints = Math.floor(totalSamples / samplesPerSegment);\n const remainingSamples = totalSamples % samplesPerSegment;\n\n const dataPoints = []\n\n // Process full segments\n for (let i = 0; i < numPoints; i++) {\n const startIdx = i * samplesPerSegment\n const endIdx = startIdx + samplesPerSegment\n \n let sumSquares = 0\n let maxAmp = 0\n let zeroCrossings = 0\n\n // Calculate segment features\n for (let j = startIdx; j < endIdx; j++) {\n const value = channelData[j]\n sumSquares += value * value\n maxAmp = Math.max(maxAmp, Math.abs(value))\n if (j > 0 && value * channelData[j - 1] < 0) {\n zeroCrossings++\n }\n }\n\n const rms = Math.sqrt(sumSquares / samplesPerSegment)\n const startTime = subChunkStartTime + (startIdx / sampleRate)\n const endTime = subChunkStartTime + (endIdx / sampleRate)\n // Calculate byte positions correctly based on numberOfChannels and bytesPerSample\n const startPosition = startIdx * numberOfChannels * bytesPerSample\n const endPosition = endIdx * numberOfChannels * bytesPerSample\n\n var spectralFeatures = computeSpectralFeatures(channelData.slice(startIdx, endIdx), sampleRate, features);\n\n // Simply use the counter, increment after assigning\n const dataPoint = {\n id: uniqueIdCounter++,\n amplitude: maxAmp,\n rms,\n startTime,\n endTime,\n dB: 20 * Math.log10(rms + 1e-6),\n silent: rms < 0.01,\n startPosition,\n endPosition,\n samples: samplesPerSegment,\n }\n\n // Extract features if any are requested\n const extractedFeatures = createFeaturesObject(\n features,\n maxAmp,\n rms,\n sumSquares,\n zeroCrossings,\n samplesPerSegment,\n spectralFeatures,\n channelData,\n startIdx,\n endIdx,\n sampleRate,\n numberOfChannels,\n bytesPerSample\n );\n \n if (extractedFeatures) {\n dataPoint.features = extractedFeatures;\n }\n\n dataPoints.push(dataPoint)\n }\n\n // Handle remaining samples if they exist and are enough to process\n if (remainingSamples > samplesPerSegment / 4) { // Only process if we have at least 1/4 of a segment\n const startIdx = numPoints * samplesPerSegment\n const endIdx = totalSamples\n \n let sumSquares = 0\n let maxAmp = 0\n let zeroCrossings = 0\n\n for (let j = startIdx; j < endIdx; j++) {\n const value = channelData[j]\n sumSquares += value * value\n maxAmp = Math.max(maxAmp, Math.abs(value))\n if (j > 0 && value * channelData[j - 1] < 0) {\n zeroCrossings++\n }\n }\n\n const rms = Math.sqrt(sumSquares / remainingSamples)\n const startTime = subChunkStartTime + (startIdx / sampleRate);\n const endTime = subChunkStartTime + (endIdx / sampleRate);\n // Calculate byte positions correctly based on numberOfChannels and bytesPerSample\n const startPosition = startIdx * numberOfChannels * bytesPerSample\n const endPosition = endIdx * numberOfChannels * bytesPerSample\n\n var spectralFeatures = computeSpectralFeatures(channelData.slice(startIdx, endIdx), sampleRate, features);\n\n // Simply use the counter, increment after assigning\n const dataPoint = {\n id: uniqueIdCounter++,\n amplitude: maxAmp,\n rms,\n startTime,\n endTime,\n dB: 20 * Math.log10(rms + 1e-6),\n silent: rms < 0.01,\n startPosition,\n endPosition,\n samples: remainingSamples,\n }\n\n logger.debug('extractWaveform - dataPoint', dataPoint);\n // Extract features if any are requested\n const extractedFeatures = createFeaturesObject(\n features,\n maxAmp,\n rms,\n sumSquares,\n zeroCrossings,\n remainingSamples,\n spectralFeatures,\n channelData,\n startIdx,\n endIdx,\n sampleRate,\n numberOfChannels,\n bytesPerSample\n );\n \n if (extractedFeatures) {\n dataPoint.features = extractedFeatures;\n }\n\n dataPoints.push(dataPoint)\n }\n\n return {\n durationMs,\n dataPoints,\n amplitudeRange: { min, max },\n rmsRange: {\n min: 0,\n max: Math.max(Math.abs(min), Math.abs(max))\n }\n }\n }\n\n try {\n // Measure actual processing time using performance.now() for higher precision\n const processingStartTime = performance.now()\n \n const result = extractWaveform(\n channelData,\n sampleRate,\n segmentDurationMs,\n numberOfChannels || 1, // Default to 1 channel if not provided\n bytesPerSample\n )\n \n const processingEndTime = performance.now()\n const actualExtractionTimeMs = processingEndTime - processingStartTime\n\n // Send complete result immediately\n self.postMessage({\n command: 'features',\n result: {\n bitDepth,\n samples: channelData.length,\n numberOfChannels,\n sampleRate,\n segmentDurationMs,\n durationMs: result.durationMs,\n dataPoints: result.dataPoints,\n amplitudeRange: result.amplitudeRange,\n rmsRange: result.rmsRange,\n extractionTimeMs: actualExtractionTimeMs,\n }\n })\n } catch (error) {\n console.error('[Worker] Error', {\n message: error.message,\n stack: error.stack\n });\n \n self.postMessage({ \n error: {\n message: error.message,\n stack: error.stack,\n name: error.name\n }\n });\n }\n}\n";
2
2
  //# sourceMappingURL=InlineFeaturesExtractor.web.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"InlineFeaturesExtractor.web.d.ts","sourceRoot":"","sources":["../../../src/workers/InlineFeaturesExtractor.web.tsx"],"names":[],"mappings":"AACA,eAAO,MAAM,uBAAuB,4u5BA+0BnC,CAAA"}
1
+ {"version":3,"file":"InlineFeaturesExtractor.web.d.ts","sourceRoot":"","sources":["../../../src/workers/InlineFeaturesExtractor.web.tsx"],"names":[],"mappings":"AACA,eAAO,MAAM,uBAAuB,qh6BAq1BnC,CAAA"}
@@ -584,7 +584,7 @@ class AudioDeviceManager {
584
584
  }
585
585
  }
586
586
 
587
- /// Handles route change notifications to detect device disconnections
587
+ /// Handles route change notifications to detect device connections and disconnections
588
588
  @objc private func handleRouteChange(_ notification: Notification) {
589
589
  guard let userInfo = notification.userInfo,
590
590
  let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
@@ -594,7 +594,7 @@ class AudioDeviceManager {
594
594
 
595
595
  Logger.debug("AudioDeviceManager", "Route change detected, reason: \(reason.rawValue)")
596
596
 
597
- // Only proceed if a device was potentially removed or the route changed significantly
597
+ // Only proceed if a device was potentially removed or added or the route changed significantly
598
598
  guard reason == .oldDeviceUnavailable || reason == .newDeviceAvailable || reason == .override || reason == .routeConfigurationChange else {
599
599
  Logger.debug("AudioDeviceManager", "Ignoring route change reason: \(reason.rawValue)")
600
600
  return
@@ -602,24 +602,36 @@ class AudioDeviceManager {
602
602
 
603
603
  // Get the *previous* route description
604
604
  guard let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription else {
605
- Logger.debug("AudioDeviceManager", "No previous route info found for disconnection check.")
605
+ Logger.debug("AudioDeviceManager", "No previous route info found for device change check.")
606
606
  return
607
607
  }
608
608
 
609
609
  // Get the *current* available input devices
610
610
  let currentInputs = AVAudioSession.sharedInstance().availableInputs ?? []
611
611
  let currentInputIds = Set(currentInputs.map { normalizeBluetoothDeviceId($0.uid) })
612
+ let previousInputIds = Set(previousRoute.inputs.map { normalizeBluetoothDeviceId($0.uid) })
612
613
 
613
- // Check which inputs from the *previous* route are *no longer* available
614
+ // Check for DISCONNECTED devices (were in previous route but not in current available)
614
615
  for previousInputPort in previousRoute.inputs {
615
616
  let normalizedPreviousId = normalizeBluetoothDeviceId(previousInputPort.uid)
616
- // Check if the previously connected input is NOT in the set of currently available inputs
617
617
  if !currentInputIds.contains(normalizedPreviousId) {
618
618
  Logger.debug("AudioDeviceManager", "Detected disconnection of device: \(previousInputPort.portName) (Normalized ID: \(normalizedPreviousId))")
619
- // Notify the delegate (AudioStreamManager) about the specific disconnected device
620
- delegate?.audioDeviceManager(self, didDetectDisconnectionOfDevice: normalizedPreviousId)
621
- // Found a disconnected device, can stop checking previous inputs for this event
622
- break
619
+ // Keep existing disconnection delegate method unchanged
620
+ delegate?.audioDeviceManager(self, didDetectDisconnectionOfDevice: normalizedPreviousId)
621
+ }
622
+ }
623
+
624
+ // Check for CONNECTED devices (are in current available but were not in previous route)
625
+ for currentInput in currentInputs {
626
+ let normalizedCurrentId = normalizeBluetoothDeviceId(currentInput.uid)
627
+ if !previousInputIds.contains(normalizedCurrentId) {
628
+ Logger.debug("AudioDeviceManager", "Detected connection of device: \(currentInput.portName) (Normalized ID: \(normalizedCurrentId))")
629
+ // Emit connection event via notification
630
+ NotificationCenter.default.post(
631
+ name: NSNotification.Name("DeviceConnected"),
632
+ object: nil,
633
+ userInfo: ["deviceId": normalizedCurrentId]
634
+ )
623
635
  }
624
636
  }
625
637
  }
@@ -18,7 +18,7 @@ private let audioDeviceTypeWiredHeadphones = "wired_headphones"
18
18
  private let audioDeviceTypeSpeaker = "speaker"
19
19
  private let audioDeviceTypeUnknown = "unknown"
20
20
 
21
- public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
21
+ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate, AudioDeviceManagerDelegate {
22
22
  private var streamManager = AudioStreamManager()
23
23
  private let notificationCenter = UNUserNotificationCenter.current()
24
24
  private let notificationIdentifier = "audio_recording_notification"
@@ -41,11 +41,30 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
41
41
  OnCreate {
42
42
  Logger.debug("ExpoAudioStreamModule", "Module created, setting delegate and starting device monitoring.")
43
43
  streamManager.delegate = self
44
+ // Set up device manager delegate to emit device change events
45
+ deviceManager.delegate = self
46
+
47
+ // Listen for device connection notifications (minimal addition)
48
+ NotificationCenter.default.addObserver(
49
+ forName: NSNotification.Name("DeviceConnected"),
50
+ object: nil,
51
+ queue: .main
52
+ ) { [weak self] notification in
53
+ if let deviceId = notification.userInfo?["deviceId"] as? String {
54
+ Logger.debug("ExpoAudioStreamModule", "Device connected: \(deviceId)")
55
+ self?.sendEvent(deviceChangedEvent, [
56
+ "type": "deviceConnected",
57
+ "deviceId": deviceId
58
+ ])
59
+ }
60
+ }
44
61
  }
45
62
 
46
63
  OnDestroy {
47
64
  Logger.debug("ExpoAudioStreamModule", "Module destroyed, stopping device monitoring.")
48
65
  _ = streamManager.stopRecording()
66
+ // Clear device manager delegate
67
+ deviceManager.delegate = nil
49
68
  }
50
69
 
51
70
  /// Extracts audio analysis data from an audio file.
@@ -980,4 +999,17 @@ public class ExpoAudioStreamModule: Module, AudioStreamManagerDelegate {
980
999
  Logger.error("ExpoAudioStreamModule", "Delegate: didFailWithError: \(error)")
981
1000
  sendEvent(errorEvent, [ "message": error ])
982
1001
  }
1002
+
1003
+ // MARK: - AudioDeviceManagerDelegate
1004
+
1005
+ /// Handles device disconnection events from the AudioDeviceManager
1006
+ func audioDeviceManager(_ manager: AudioDeviceManager, didDetectDisconnectionOfDevice deviceId: String) {
1007
+ Logger.debug("ExpoAudioStreamModule", "Device disconnected: \(deviceId)")
1008
+
1009
+ // Emit device change event to match Android implementation
1010
+ sendEvent(deviceChangedEvent, [
1011
+ "type": "deviceDisconnected",
1012
+ "deviceId": deviceId
1013
+ ])
1014
+ }
983
1015
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteed/expo-audio-studio",
3
- "version": "2.12.3",
3
+ "version": "2.13.1",
4
4
  "description": "Comprehensive audio processing library for React Native and Expo with recording, analysis, visualization, and streaming capabilities across iOS, Android, and web",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -74,11 +74,12 @@
74
74
  "build:cjs": "tsc -p tsconfig.cjs.json",
75
75
  "build:esm": "tsc -p tsconfig.esm.json",
76
76
  "build:types": "tsc -p tsconfig.types.json",
77
- "build:plugin": "tsc --build plugin/tsconfig.json",
77
+ "build:plugin": "tsc --project plugin/tsconfig.json && cp plugin/build/index.js plugin/build/index.cjs",
78
78
  "build:plugin:dev": "expo-module build plugin",
79
79
  "build:dev": "expo-module build",
80
80
  "clean": "expo-module clean && rimraf build plugin/build",
81
81
  "lint": "expo-module lint",
82
+ "lint:fix": "expo-module lint --fix",
82
83
  "test": "expo-module test",
83
84
  "test:android": "yarn test:android:unit && yarn test:android:instrumented",
84
85
  "test:android:unit": "cd ../../apps/playground/android && ./gradlew :siteed-expo-audio-studio:test",
@@ -120,9 +121,11 @@
120
121
  "eslint-plugin-react": "^7.34.1",
121
122
  "expo": "^53.0.9",
122
123
  "expo-module-scripts": "^4.1.7",
124
+ "expo-modules-core": "2.4.0",
123
125
  "jest": "^29.7.0",
124
126
  "prettier": "^3.2.5",
125
- "react-native": "0.79.2",
127
+ "react": "19.0.0",
128
+ "react-native": "0.79.3",
126
129
  "rimraf": "^6.0.1",
127
130
  "size-limit": "^11.1.4",
128
131
  "ts-node": "^10.9.2",
@@ -132,14 +135,12 @@
132
135
  },
133
136
  "peerDependencies": {
134
137
  "expo": "*",
138
+ "expo-modules-core": "~2.4.0",
135
139
  "react": "*",
136
140
  "react-native": "*"
137
141
  },
138
142
  "publishConfig": {
139
143
  "access": "public",
140
144
  "registry": "https://registry.npmjs.org"
141
- },
142
- "dependencies": {
143
- "expo-modules-core": "~2.3.13"
144
145
  }
145
146
  }
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const config_plugins_1 = require("@expo/config-plugins");
4
+ const MICROPHONE_USAGE = 'Allow $(PRODUCT_NAME) to access your microphone';
5
+ const NOTIFICATION_USAGE = 'Show recording notifications and controls';
6
+ const LOG_PREFIX = '[@siteed/expo-audio-studio]';
7
+ function debugLog(message, ...args) {
8
+ if (process.env.EXPO_DEBUG) {
9
+ console.log(`${LOG_PREFIX} ${message}`, ...args);
10
+ }
11
+ }
12
+ const withRecordingPermission = (config, props) => {
13
+ const options = {
14
+ enablePhoneStateHandling: true, // Default to true for backward compatibility
15
+ enableNotifications: true,
16
+ enableBackgroundAudio: true,
17
+ enableDeviceDetection: true, // Default to true for backward compatibility
18
+ iosBackgroundModes: {
19
+ useVoIP: false,
20
+ useAudio: false,
21
+ useProcessing: false,
22
+ useLocation: false,
23
+ useExternalAccessory: false,
24
+ },
25
+ iosConfig: {
26
+ microphoneUsageDescription: MICROPHONE_USAGE,
27
+ notificationUsageDescription: NOTIFICATION_USAGE,
28
+ },
29
+ ...(props || {}),
30
+ };
31
+ const { enablePhoneStateHandling, enableNotifications, enableBackgroundAudio, enableDeviceDetection, } = options;
32
+ debugLog('📱 Configuring Recording Permissions Plugin...', options);
33
+ // iOS Configuration
34
+ config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
35
+ // Always set the microphone usage description from options first
36
+ config.modResults['NSMicrophoneUsageDescription'] =
37
+ options.iosConfig?.microphoneUsageDescription ||
38
+ config.modResults['NSMicrophoneUsageDescription'] ||
39
+ MICROPHONE_USAGE;
40
+ if (enableNotifications) {
41
+ config.modResults['NSUserNotificationsUsageDescription'] =
42
+ options.iosConfig?.notificationUsageDescription ||
43
+ config.modResults['NSUserNotificationsUsageDescription'] ||
44
+ NOTIFICATION_USAGE;
45
+ config.modResults['NSUserNotificationAlertStyle'] = 'alert';
46
+ }
47
+ const existingBackgroundModes = config.modResults.UIBackgroundModes || [];
48
+ // If background audio is enabled with useAudio, add the audio background mode
49
+ if (options.iosBackgroundModes?.useAudio === true &&
50
+ enableBackgroundAudio === true &&
51
+ !existingBackgroundModes.includes('audio')) {
52
+ // Add 'audio' background mode - REQUIRED for background recording
53
+ existingBackgroundModes.push('audio');
54
+ debugLog('✅ Added audio background mode for iOS background recording');
55
+ // Also ensure processing mode is recommended
56
+ if (options.iosBackgroundModes?.useProcessing !== true) {
57
+ console.warn(`${LOG_PREFIX} Warning: Background audio recording works best with both 'audio' and 'processing' background modes. Consider enabling 'useProcessing' in iosBackgroundModes.`);
58
+ }
59
+ }
60
+ if (options.iosBackgroundModes?.useVoIP === true &&
61
+ enablePhoneStateHandling === true) {
62
+ if (!existingBackgroundModes.includes('voip')) {
63
+ existingBackgroundModes.push('voip');
64
+ }
65
+ const existingCapabilities = (config.modResults
66
+ .UIRequiredDeviceCapabilities || []);
67
+ if (!existingCapabilities.includes('telephony')) {
68
+ existingCapabilities.push('telephony');
69
+ }
70
+ config.modResults.UIRequiredDeviceCapabilities =
71
+ existingCapabilities;
72
+ }
73
+ // Add additional background modes only if explicitly set to true
74
+ if (options.iosBackgroundModes?.useProcessing === true) {
75
+ if (!existingBackgroundModes.includes('processing')) {
76
+ existingBackgroundModes.push('processing');
77
+ }
78
+ // Add processing info if enabled
79
+ // Note: We keep the 'audiostream' namespace for native modules to maintain compatibility
80
+ config.modResults.BGTaskSchedulerPermittedIdentifiers = [
81
+ 'com.siteed.audiostream.processing',
82
+ ];
83
+ }
84
+ if (options.iosBackgroundModes?.useLocation === true) {
85
+ if (!existingBackgroundModes.includes('location')) {
86
+ existingBackgroundModes.push('location');
87
+ }
88
+ }
89
+ if (options.iosBackgroundModes?.useExternalAccessory === true) {
90
+ if (!existingBackgroundModes.includes('external-accessory')) {
91
+ existingBackgroundModes.push('external-accessory');
92
+ }
93
+ }
94
+ // Configure background processing info if enabled
95
+ if (options.iosConfig?.backgroundProcessingTitle) {
96
+ config.modResults.BGProcessingTaskTitle =
97
+ options.iosConfig.backgroundProcessingTitle;
98
+ }
99
+ // Configure audio session behavior
100
+ if (options.iosConfig?.allowBackgroundAudioControls) {
101
+ config.modResults.UIBackgroundModes = [
102
+ ...existingBackgroundModes,
103
+ 'remote-notification',
104
+ ];
105
+ config.modResults.MPNowPlayingInfoPropertyPlaybackRate = true;
106
+ }
107
+ config.modResults.UIBackgroundModes = existingBackgroundModes;
108
+ return config;
109
+ });
110
+ // Android Configuration
111
+ config = (0, config_plugins_1.withAndroidManifest)(config, (config) => {
112
+ const basePermissions = [
113
+ 'android.permission.RECORD_AUDIO',
114
+ 'android.permission.WAKE_LOCK',
115
+ ];
116
+ const optionalPermissions = [
117
+ enableNotifications && 'android.permission.POST_NOTIFICATIONS',
118
+ enablePhoneStateHandling && 'android.permission.READ_PHONE_STATE', // Only add if enabled
119
+ enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE',
120
+ enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE_MICROPHONE',
121
+ // Device detection permissions (only if enabled)
122
+ enableDeviceDetection && 'android.permission.BLUETOOTH',
123
+ enableDeviceDetection && 'android.permission.BLUETOOTH_CONNECT',
124
+ enableDeviceDetection && 'android.permission.USB_PERMISSION',
125
+ ].filter(Boolean);
126
+ const permissionsToAdd = [...basePermissions, ...optionalPermissions];
127
+ debugLog('📋 Existing Android permissions:', config.modResults.manifest['uses-permission']?.map((p) => p.$?.['android:name']) || []);
128
+ debugLog('➕ Adding Android permissions:', permissionsToAdd);
129
+ // Add each permission only if it doesn't exist
130
+ permissionsToAdd.forEach((permission) => {
131
+ config_plugins_1.AndroidConfig.Permissions.addPermission(config.modResults, permission);
132
+ });
133
+ // Get the main application node
134
+ const mainApplication = config.modResults.manifest.application?.[0];
135
+ if (mainApplication) {
136
+ debugLog('📱 Configuring Android application components...');
137
+ // Add RecordingActionReceiver
138
+ if (!mainApplication.receiver) {
139
+ mainApplication.receiver = [];
140
+ }
141
+ const receiverConfig = {
142
+ $: {
143
+ 'android:name': '.RecordingActionReceiver',
144
+ 'android:exported': 'false',
145
+ },
146
+ 'intent-filter': [
147
+ {
148
+ action: [
149
+ { $: { 'android:name': 'PAUSE_RECORDING' } },
150
+ { $: { 'android:name': 'RESUME_RECORDING' } },
151
+ { $: { 'android:name': 'STOP_RECORDING' } },
152
+ ],
153
+ },
154
+ ],
155
+ };
156
+ const receiverIndex = mainApplication.receiver.findIndex((receiver) => receiver.$?.['android:name'] === '.RecordingActionReceiver');
157
+ if (receiverIndex >= 0) {
158
+ mainApplication.receiver[receiverIndex] = receiverConfig;
159
+ }
160
+ else {
161
+ mainApplication.receiver.push(receiverConfig);
162
+ }
163
+ debugLog('✅ RecordingActionReceiver configured');
164
+ // Add AudioRecordingService
165
+ if (!mainApplication.service) {
166
+ mainApplication.service = [];
167
+ }
168
+ const serviceConfig = {
169
+ $: {
170
+ 'android:name': '.AudioRecordingService',
171
+ 'android:enabled': 'true',
172
+ 'android:exported': 'false',
173
+ 'android:foregroundServiceType': 'microphone',
174
+ },
175
+ };
176
+ const serviceIndex = mainApplication.service.findIndex((service) => service.$?.['android:name'] === '.AudioRecordingService');
177
+ if (serviceIndex >= 0) {
178
+ mainApplication.service[serviceIndex] = serviceConfig;
179
+ }
180
+ else {
181
+ mainApplication.service.push(serviceConfig);
182
+ }
183
+ debugLog('✅ AudioRecordingService configured');
184
+ }
185
+ else {
186
+ console.error(`${LOG_PREFIX} ❌ Main application node not found in Android Manifest`);
187
+ }
188
+ return config;
189
+ });
190
+ debugLog('✨ Recording Permissions Plugin configuration completed');
191
+ return config;
192
+ };
193
+ // Export as default
194
+ exports.default = withRecordingPermission;
@@ -3,6 +3,7 @@ interface AudioStreamPluginOptions {
3
3
  enablePhoneStateHandling?: boolean;
4
4
  enableNotifications?: boolean;
5
5
  enableBackgroundAudio?: boolean;
6
+ enableDeviceDetection?: boolean;
6
7
  iosBackgroundModes?: {
7
8
  useVoIP?: boolean;
8
9
  useAudio?: boolean;
@@ -14,6 +14,7 @@ const withRecordingPermission = (config, props) => {
14
14
  enablePhoneStateHandling: true, // Default to true for backward compatibility
15
15
  enableNotifications: true,
16
16
  enableBackgroundAudio: true,
17
+ enableDeviceDetection: true, // Default to true for backward compatibility
17
18
  iosBackgroundModes: {
18
19
  useVoIP: false,
19
20
  useAudio: false,
@@ -27,7 +28,7 @@ const withRecordingPermission = (config, props) => {
27
28
  },
28
29
  ...(props || {}),
29
30
  };
30
- const { enablePhoneStateHandling, enableNotifications, enableBackgroundAudio, } = options;
31
+ const { enablePhoneStateHandling, enableNotifications, enableBackgroundAudio, enableDeviceDetection, } = options;
31
32
  debugLog('📱 Configuring Recording Permissions Plugin...', options);
32
33
  // iOS Configuration
33
34
  config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
@@ -117,17 +118,17 @@ const withRecordingPermission = (config, props) => {
117
118
  enablePhoneStateHandling && 'android.permission.READ_PHONE_STATE', // Only add if enabled
118
119
  enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE',
119
120
  enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE_MICROPHONE',
121
+ // Device detection permissions (only if enabled)
122
+ enableDeviceDetection && 'android.permission.BLUETOOTH',
123
+ enableDeviceDetection && 'android.permission.BLUETOOTH_CONNECT',
124
+ enableDeviceDetection && 'android.permission.USB_PERMISSION',
120
125
  ].filter(Boolean);
121
126
  const permissionsToAdd = [...basePermissions, ...optionalPermissions];
122
127
  debugLog('📋 Existing Android permissions:', config.modResults.manifest['uses-permission']?.map((p) => p.$?.['android:name']) || []);
123
128
  debugLog('➕ Adding Android permissions:', permissionsToAdd);
124
- const { addPermission } = config_plugins_1.AndroidConfig.Permissions;
125
129
  // Add each permission only if it doesn't exist
126
130
  permissionsToAdd.forEach((permission) => {
127
- const existingPermission = config.modResults.manifest['uses-permission']?.find((p) => p.$?.['android:name'] === permission);
128
- if (!existingPermission) {
129
- addPermission(config.modResults, permission);
130
- }
131
+ config_plugins_1.AndroidConfig.Permissions.addPermission(config.modResults, permission);
131
132
  });
132
133
  // Get the main application node
133
134
  const mainApplication = config.modResults.manifest.application?.[0];
@@ -20,6 +20,7 @@ interface AudioStreamPluginOptions {
20
20
  enablePhoneStateHandling?: boolean // Controls READ_PHONE_STATE permission
21
21
  enableNotifications?: boolean
22
22
  enableBackgroundAudio?: boolean
23
+ enableDeviceDetection?: boolean // Controls Bluetooth and USB permissions for device change detection
23
24
  iosBackgroundModes?: {
24
25
  useVoIP?: boolean
25
26
  useAudio?: boolean
@@ -43,6 +44,7 @@ const withRecordingPermission: ConfigPlugin<AudioStreamPluginOptions> = (
43
44
  enablePhoneStateHandling: true, // Default to true for backward compatibility
44
45
  enableNotifications: true,
45
46
  enableBackgroundAudio: true,
47
+ enableDeviceDetection: true, // Default to true for backward compatibility
46
48
  iosBackgroundModes: {
47
49
  useVoIP: false,
48
50
  useAudio: false,
@@ -61,6 +63,7 @@ const withRecordingPermission: ConfigPlugin<AudioStreamPluginOptions> = (
61
63
  enablePhoneStateHandling,
62
64
  enableNotifications,
63
65
  enableBackgroundAudio,
66
+ enableDeviceDetection,
64
67
  } = options
65
68
 
66
69
  debugLog('📱 Configuring Recording Permissions Plugin...', options)
@@ -175,6 +178,10 @@ const withRecordingPermission: ConfigPlugin<AudioStreamPluginOptions> = (
175
178
  enablePhoneStateHandling && 'android.permission.READ_PHONE_STATE', // Only add if enabled
176
179
  enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE',
177
180
  enableBackgroundAudio && 'android.permission.FOREGROUND_SERVICE_MICROPHONE',
181
+ // Device detection permissions (only if enabled)
182
+ enableDeviceDetection && 'android.permission.BLUETOOTH',
183
+ enableDeviceDetection && 'android.permission.BLUETOOTH_CONNECT',
184
+ enableDeviceDetection && 'android.permission.USB_PERMISSION',
178
185
  ].filter(Boolean) as string[]
179
186
 
180
187
  const permissionsToAdd = [...basePermissions, ...optionalPermissions]
@@ -188,16 +195,9 @@ const withRecordingPermission: ConfigPlugin<AudioStreamPluginOptions> = (
188
195
 
189
196
  debugLog('➕ Adding Android permissions:', permissionsToAdd)
190
197
 
191
- const { addPermission } = AndroidConfig.Permissions
192
-
193
198
  // Add each permission only if it doesn't exist
194
199
  permissionsToAdd.forEach((permission) => {
195
- const existingPermission = config.modResults.manifest[
196
- 'uses-permission'
197
- ]?.find((p) => p.$?.['android:name'] === permission)
198
- if (!existingPermission) {
199
- addPermission(config.modResults, permission)
200
- }
200
+ AndroidConfig.Permissions.addPermission(config.modResults, permission)
201
201
  })
202
202
 
203
203
  // Get the main application node
@@ -121,6 +121,7 @@ export interface AudioAnalysis {
121
121
  min: number
122
122
  max: number
123
123
  }
124
+ extractionTimeMs: number // Time taken to extract/process the analysis in milliseconds
124
125
  // TODO: speaker changes into a broader speech analysis section
125
126
  speechAnalysis?: {
126
127
  speakerChanges: {