import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js"; import { interpolateFrequencyResponse, inverseResponseGainDb, requireMaxCompensationDb, } from "./frequency-response.js"; import { cooperativeYield } from "./cooperative-yield.js"; import { DEFAULT_PHAT_RELATIVE_FLOOR, isDasAlgorithm, requireLocalizationAlgorithmDefinition, } from "./localization-algorithms.js"; import { binsForBand, createStftWorkspace, extractStftBins, getFrameCount } from "./stft.js"; function throwIfAborted(signal) { if (signal?.aborted) { const error = new Error("계산이 취소되었습니다."); error.name = "AbortError"; throw error; } } export const DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES = 64 * 1024 * 1024; export const DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES = 512 * 1024 * 1024; const DAS_ANALYSIS_PLAN_KIND = "micarrsim.das-analysis-plan/1"; export function createDistanceMatrix(arrayDefinition, scan) { const points = createScanPoints(scan); const microphoneCount = arrayDefinition.microphones.length; const pointCount = scan.columns * scan.rows; const distances = new Float32Array(pointCount * microphoneCount); for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { const pointOffset = pointIndex * 4; const point = [points[pointOffset], points[pointOffset + 1], points[pointOffset + 2]]; for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { distances[pointIndex * microphoneCount + microphoneIndex] = distance3( point, arrayDefinition.microphones[microphoneIndex].position, ); } } return distances; } function createBandIndexLookup(bandBins) { const lookup = new Map(); bandBins.forEach((bins, bandIndex) => { bins.forEach((bin) => { if (!lookup.has(bin)) lookup.set(bin, []); lookup.get(bin).push(bandIndex); }); }); return lookup; } export function createBinCalibration(options) { const { arrayDefinition, bin, sampleRate, fftSize, compensateFrequencyResponse = true, maxCompensationDb: maximumCompensationDb, includeAmplitude = true, } = options; const microphoneCount = arrayDefinition.microphones.length; const gains = new Float64Array(microphoneCount); const phases = new Float64Array(microphoneCount); const frequencyHz = (bin * sampleRate) / fftSize; const response = {}; const maxCompensationDb = includeAmplitude ? requireMaxCompensationDb(maximumCompensationDb) : 0; for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { const microphone = arrayDefinition.microphones[microphoneIndex]; let responseGainDb = 0; let responsePhaseDeg = 0; if (compensateFrequencyResponse && microphone.frequencyResponse) { interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response); responseGainDb = response.gainDb; responsePhaseDeg = response.phaseDeg; } const responseCompensationDb = includeAmplitude && compensateFrequencyResponse ? inverseResponseGainDb(responseGainDb, maxCompensationDb) : 0; gains[microphoneIndex] = includeAmplitude ? dbToLinear(-(microphone.sensitivityDb ?? 0) + responseCompensationDb) : 1; phases[microphoneIndex] = ((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180; } return { gains, phases }; } function requirePhatRelativeFloor(value) { if (!Number.isFinite(value) || value < 0 || value > 1) { throw new RangeError("phatRelativeFloor must lie inside 0..1."); } return value; } export function phaseNormalizeSpectrum( spectrum, frameCount, microphoneCount, relativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR, ) { const expectedLength = frameCount * microphoneCount; if ( !spectrum || spectrum.real?.length !== expectedLength || spectrum.imaginary?.length !== expectedLength ) { throw new RangeError("spectrum length must match frameCount and microphoneCount."); } requirePhatRelativeFloor(relativeFloor); const real = new Float32Array(expectedLength); const imaginary = new Float32Array(expectedLength); for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { const frameOffset = frameIndex * microphoneCount; let maximumMagnitude = 0; for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { const spectrumIndex = frameOffset + microphoneIndex; maximumMagnitude = Math.max( maximumMagnitude, Math.hypot(spectrum.real[spectrumIndex], spectrum.imaginary[spectrumIndex]), ); } const magnitudeFloor = maximumMagnitude * relativeFloor; for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { const spectrumIndex = frameOffset + microphoneIndex; const inputReal = spectrum.real[spectrumIndex]; const inputImaginary = spectrum.imaginary[spectrumIndex]; const magnitude = Math.hypot(inputReal, inputImaginary); if (!(magnitude > magnitudeFloor)) continue; real[spectrumIndex] = inputReal / magnitude; imaginary[spectrumIndex] = inputImaginary / magnitude; } } return { real, imaginary }; } function fillSteeringVector({ angularWaveNumber, distances, distanceOffset, calibration, applyCalibrationGains = true, outputReal, outputImaginary, outputOffset, }) { for (let microphoneIndex = 0; microphoneIndex < calibration.gains.length; microphoneIndex += 1) { const phase = angularWaveNumber * distances[distanceOffset + microphoneIndex] - calibration.phases[microphoneIndex]; const gain = applyCalibrationGains ? calibration.gains[microphoneIndex] : 1; outputReal[outputOffset + microphoneIndex] = Math.cos(phase) * gain; outputImaginary[outputOffset + microphoneIndex] = Math.sin(phase) * gain; } } async function createSteeringMatrix(options) { const { bin, pointCount, microphoneCount, distances, sampleRate, fftSize, speedOfSound, calibration, applyCalibrationGains = true, signal, pointBatchSize, } = options; const real = new Float64Array(pointCount * microphoneCount); const imaginary = new Float64Array(pointCount * microphoneCount); const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound); for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { throwIfAborted(signal); const offset = pointIndex * microphoneCount; fillSteeringVector({ angularWaveNumber, distances, distanceOffset: offset, calibration, applyCalibrationGains, outputReal: real, outputImaginary: imaginary, outputOffset: offset, }); if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) { await cooperativeYield(); } } return { real, imaginary }; } export async function computeBinMap(options) { const { bin, spectrum, frameCount, arrayDefinition, pointCount, distances, sampleRate, fftSize, speedOfSound, compensateFrequencyResponse = true, maxCompensationDb, calibration: providedCalibration, steeringMatrix, output: providedOutput, steeringReal: providedSteeringReal, steeringImaginary: providedSteeringImaginary, signal, pointBatchSize = 64, algorithm: algorithmId = "das", phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR, } = options; const algorithm = requireLocalizationAlgorithmDefinition(algorithmId); if (!isDasAlgorithm(algorithm.id)) { throw new RangeError(`computeBinMap does not implement ${algorithm.id}.`); } const phaseOnly = algorithm.spectralWeighting === "phat"; const microphoneCount = arrayDefinition.microphones.length; const output = providedOutput ?? new Float32Array(pointCount); const steeringReal = providedSteeringReal ?? new Float64Array(microphoneCount); const steeringImaginary = providedSteeringImaginary ?? new Float64Array(microphoneCount); const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound); const microphoneScale = 1 / microphoneCount; const calibration = providedCalibration ?? createBinCalibration({ arrayDefinition, bin, sampleRate, fftSize, compensateFrequencyResponse, maxCompensationDb, includeAmplitude: !phaseOnly, }); const inputSpectrum = phaseOnly ? phaseNormalizeSpectrum(spectrum, frameCount, microphoneCount, phatRelativeFloor) : spectrum; const phaseRealValues = steeringMatrix?.real ?? steeringReal; const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary; if (output.length !== pointCount) throw new RangeError("output length must match pointCount."); if (steeringReal.length !== microphoneCount || steeringImaginary.length !== microphoneCount) { throw new RangeError("steering workspace length must match microphone count."); } if ( calibration.gains.length !== microphoneCount || calibration.phases.length !== microphoneCount ) { throw new RangeError("calibration length must match microphone count."); } if ( steeringMatrix && ( steeringMatrix.real.length !== pointCount * microphoneCount || steeringMatrix.imaginary.length !== pointCount * microphoneCount ) ) { throw new RangeError("steering matrix length must match point and microphone counts."); } for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { throwIfAborted(signal); const distanceOffset = pointIndex * microphoneCount; const steeringOffset = steeringMatrix ? distanceOffset : 0; if (!steeringMatrix) { fillSteeringVector({ angularWaveNumber, distances, distanceOffset, calibration, outputReal: steeringReal, outputImaginary: steeringImaginary, outputOffset: 0, applyCalibrationGains: !phaseOnly, }); } let accumulatedPower = 0; for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { const spectrumOffset = frameIndex * microphoneCount; let sumReal = 0; let sumImaginary = 0; for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { const spectrumIndex = spectrumOffset + microphoneIndex; const inputReal = inputSpectrum.real[spectrumIndex]; const inputImaginary = inputSpectrum.imaginary[spectrumIndex]; const phaseReal = phaseRealValues[steeringOffset + microphoneIndex]; const phaseImaginary = phaseImaginaryValues[steeringOffset + microphoneIndex]; sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary; sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal; } accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale; } output[pointIndex] = accumulatedPower / frameCount; if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) { await cooperativeYield(); } } return output; } /** * Cache geometry and optional steering matrices for repeated CPU DAS analyses. * * Plans are intentionally stateful and must not be used concurrently. They are * local runtime objects (Maps and workspaces), not serializable data contracts. */ export function createDasAnalysisPlan(options) { const { sampleRate, arrayDefinition, scene, experiment, maximumSteeringCacheBytes = DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES, maximumPlanBytes = DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES, } = options; if (!Number.isFinite(sampleRate) || sampleRate <= 0) { throw new RangeError("sampleRate must be a positive finite number."); } if (!Array.isArray(arrayDefinition?.microphones) || arrayDefinition.microphones.length === 0) { throw new TypeError("arrayDefinition.microphones must be a non-empty array."); } if (!Number.isFinite(scene?.speedOfSound) || scene.speedOfSound <= 0) { throw new RangeError("scene.speedOfSound must be a positive finite number."); } if (!Number.isFinite(maximumSteeringCacheBytes) || maximumSteeringCacheBytes < 0) { throw new RangeError("maximumSteeringCacheBytes must be a non-negative finite number."); } if (!Number.isSafeInteger(maximumPlanBytes) || maximumPlanBytes <= 0) { throw new RangeError("maximumPlanBytes must be a positive safe integer."); } const algorithm = requireLocalizationAlgorithmDefinition( experiment.beamforming?.algorithm ?? "das", ); if (!isDasAlgorithm(algorithm.id)) { throw new RangeError(`createDasAnalysisPlan does not implement ${algorithm.id}.`); } const { fftSize } = experiment.stft; const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize)); const selectedBins = [...new Set(bandBins.flat())].sort((a, b) => a - b); const microphoneCount = arrayDefinition.microphones.length; const pointCount = experiment.scan.columns * experiment.scan.rows; const pointMicrophonePairs = pointCount * microphoneCount; const distanceMatrixBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT; const stftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3; const calibrationBytes = selectedBins.length * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; const retainedBaseBytes = distanceMatrixBytes + pointCount * Float32Array.BYTES_PER_ELEMENT + microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2 + stftWorkspaceBytes; const retainedNonSteeringBytes = retainedBaseBytes + calibrationBytes; if ( !Number.isSafeInteger(pointMicrophonePairs) || !Number.isSafeInteger(retainedBaseBytes) || !Number.isSafeInteger(calibrationBytes) || !Number.isSafeInteger(retainedNonSteeringBytes) || retainedNonSteeringBytes > maximumPlanBytes ) { throw new RangeError( "CPU analysis plan requires " + retainedNonSteeringBytes.toLocaleString() + " non-steering bytes including " + calibrationBytes.toLocaleString() + " calibration bytes, above maximumPlanBytes " + maximumPlanBytes.toLocaleString() + ".", ); } const distances = createDistanceMatrix(arrayDefinition, experiment.scan); const binMap = new Float32Array(pointCount); const steeringReal = new Float64Array(microphoneCount); const steeringImaginary = new Float64Array(microphoneCount); const stftWorkspace = createStftWorkspace(fftSize); const steeringBytesPerBin = pointCount * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; const requestedSteeringCacheBytes = steeringBytesPerBin * selectedBins.length; const steeringCacheBinCapacity = Math.min( selectedBins.length, Math.floor(maximumSteeringCacheBytes / steeringBytesPerBin), Math.floor((maximumPlanBytes - retainedNonSteeringBytes) / steeringBytesPerBin), ); const steeringCacheBytes = steeringCacheBinCapacity * steeringBytesPerBin; const steeringCacheEnabled = steeringCacheBinCapacity > 0; const steeringCachedBins = new Set(selectedBins.slice(0, steeringCacheBinCapacity)); return { kind: DAS_ANALYSIS_PLAN_KIND, sampleRate, arrayDefinition, scene, experiment, algorithm, microphoneCount, pointCount, bandBins, selectedBins, distances, bandIndexesByBin: createBandIndexLookup(bandBins), calibrations: new Map(), steeringMatrices: new Map(), steeringCacheEnabled, steeringCacheBinCapacity, steeringCachedBins, steeringBytesPerBin, steeringCacheBytes, requestedSteeringCacheBytes, retainedBaseBytes, calibrationBytes, retainedNonSteeringBytes, maximumSteeringCacheBytes, maximumPlanBytes, binMap, steeringReal, steeringImaginary, stftWorkspace, busy: false, statistics: { analysisAttempts: 0, completedAnalyses: 0, distanceMatrixBuilds: 1, distanceEvaluations: pointCount * microphoneCount, avoidedDistanceEvaluations: 0, calibrationBuilds: 0, calibrationCacheHits: 0, steeringMatrixBuilds: 0, steeringCacheHits: 0, uncachedSteeringUses: 0, steeringPointMicrophonePairs: 0, uncachedSteeringPointMicrophonePairs: 0, avoidedSteeringPointMicrophonePairs: 0, stftWindowBuilds: 1, stftWorkspaceAllocations: 1, stftWorkspaceUses: 0, avoidedStftWindowBuilds: 0, avoidedStftWorkspaceAllocations: 0, stftTransforms: 0, fftButterflies: 0, beamformingComplexMacs: 0, binMapAllocations: 1, steeringWorkspaceAllocations: 1, binMapUses: 0, avoidedBinMapAllocations: 0, retainedBytes: retainedBaseBytes, }, }; } function assertCompatibleAnalysisPlan(plan, options) { if (plan?.kind !== DAS_ANALYSIS_PLAN_KIND) { throw new TypeError("analysisPlan was not created by createDasAnalysisPlan()."); } if ( plan.sampleRate !== options.sampleRate || plan.arrayDefinition !== options.arrayDefinition || plan.scene !== options.scene || plan.experiment !== options.experiment ) { throw new Error("analysisPlan must be used with the exact configuration objects that created it."); } } function planCalibration(plan, bin) { let calibration = plan.calibrations.get(bin); if (calibration) { plan.statistics.calibrationCacheHits += 1; return calibration; } calibration = createBinCalibration({ arrayDefinition: plan.arrayDefinition, bin, sampleRate: plan.sampleRate, fftSize: plan.experiment.stft.fftSize, compensateFrequencyResponse: plan.experiment.beamforming.compensateFrequencyResponse !== false, maxCompensationDb: plan.experiment.beamforming.maxCompensationDb, includeAmplitude: plan.algorithm.spectralWeighting !== "phat", }); plan.calibrations.set(bin, calibration); plan.statistics.calibrationBuilds += 1; plan.statistics.retainedBytes += calibration.gains.byteLength + calibration.phases.byteLength; return calibration; } async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize) { if (!plan.steeringCachedBins.has(bin)) { plan.statistics.uncachedSteeringUses += 1; plan.statistics.uncachedSteeringPointMicrophonePairs += plan.pointCount * plan.microphoneCount; return undefined; } let matrix = plan.steeringMatrices.get(bin); if (matrix) { plan.statistics.steeringCacheHits += 1; plan.statistics.avoidedSteeringPointMicrophonePairs += plan.pointCount * plan.microphoneCount; return matrix; } matrix = await createSteeringMatrix({ bin, pointCount: plan.pointCount, microphoneCount: plan.microphoneCount, distances: plan.distances, sampleRate: plan.sampleRate, fftSize: plan.experiment.stft.fftSize, speedOfSound: plan.scene.speedOfSound, calibration, applyCalibrationGains: plan.algorithm.spectralWeighting !== "phat", signal, pointBatchSize, }); plan.steeringMatrices.set(bin, matrix); plan.statistics.steeringMatrixBuilds += 1; plan.statistics.steeringPointMicrophonePairs += plan.pointCount * plan.microphoneCount; plan.statistics.retainedBytes += matrix.real.byteLength + matrix.imaginary.byteLength; return matrix; } function analysisCacheSummary(plan) { return { distanceMatrixBuilds: plan.statistics.distanceMatrixBuilds, distanceEvaluations: plan.statistics.distanceEvaluations, avoidedDistanceEvaluations: plan.statistics.avoidedDistanceEvaluations, calibrationBuilds: plan.statistics.calibrationBuilds, calibrationCacheHits: plan.statistics.calibrationCacheHits, calibrationBytes: plan.calibrationBytes, steeringCacheEnabled: plan.steeringCacheEnabled, steeringCacheBinCapacity: plan.steeringCacheBinCapacity, steeringCachedBinCount: plan.steeringMatrices.size, steeringUncachedBinCount: plan.selectedBins.length - plan.steeringCacheBinCapacity, steeringCacheBytes: plan.steeringCacheBytes, requestedSteeringCacheBytes: plan.requestedSteeringCacheBytes, steeringMatrixBuilds: plan.statistics.steeringMatrixBuilds, steeringCacheHits: plan.statistics.steeringCacheHits, uncachedSteeringUses: plan.statistics.uncachedSteeringUses, steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs, uncachedSteeringPointMicrophonePairs: plan.statistics.uncachedSteeringPointMicrophonePairs, avoidedSteeringPointMicrophonePairs: plan.statistics.avoidedSteeringPointMicrophonePairs, stftWindowBuilds: plan.statistics.stftWindowBuilds, stftWorkspaceAllocations: plan.statistics.stftWorkspaceAllocations, stftWorkspaceUses: plan.statistics.stftWorkspaceUses, avoidedStftWindowBuilds: plan.statistics.avoidedStftWindowBuilds, avoidedStftWorkspaceAllocations: plan.statistics.avoidedStftWorkspaceAllocations, stftTransforms: plan.statistics.stftTransforms, fftButterflies: plan.statistics.fftButterflies, beamformingComplexMacs: plan.statistics.beamformingComplexMacs, binMapAllocations: plan.statistics.binMapAllocations, steeringWorkspaceAllocations: plan.statistics.steeringWorkspaceAllocations, avoidedBinMapAllocations: plan.statistics.avoidedBinMapAllocations, retainedBytes: plan.statistics.retainedBytes, }; } export function estimateDasWorkload(options) { const { channels, sampleRate, arrayDefinition, experiment, analysisPlan } = options; const { fftSize, hopSize } = experiment.stft; const bandBins = analysisPlan?.bandBins ?? experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize)); const selectedBins = analysisPlan?.selectedBins ?? [...new Set(bandBins.flat())].sort((a, b) => a - b); const frameCount = getFrameCount(channels[0].length, fftSize, hopSize); const microphoneCount = arrayDefinition.microphones.length; const pointCount = experiment.scan.columns * experiment.scan.rows; const bandCount = experiment.frequencyBands.length; const distanceBytes = pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT; const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2; const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT; const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3; const steeringCacheBytes = analysisPlan?.steeringCacheBytes ?? 0; const calibrationBytes = analysisPlan?.calibrationBytes ?? microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; const baseWorkingBytes = distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes + steeringCacheBytes + calibrationBytes; const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length; return { bandBins, selectedBins, frameCount, microphoneCount, pointCount, distanceBytes, outputBytes, fftWorkspaceBytes, steeringCacheBytes, calibrationBytes, steeringCacheBinCapacity: analysisPlan?.steeringCacheBinCapacity ?? 0, spectrumBytesPerBin, baseWorkingBytes, complexMacCount, }; } export function finalizeDasBands(rawMaps, workload, experiment, rawScale = "power") { if (rawScale !== "power" && rawScale !== "score") { throw new RangeError('rawScale must be "power" or "score".'); } return experiment.frequencyBands.map((band, bandIndex) => { const rawMap = rawMaps[bandIndex]; for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { rawMap[pointIndex] /= workload.bandBins[bandIndex].length; } const rawPeak = findPeak(rawMap); const displayMap = Float32Array.from(rawMap); let displayScale = rawScale; if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) { displayScale = "normalized"; for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { displayMap[pointIndex] /= rawPeak.value; } } return { id: band.id, color: band.color, bins: workload.bandBins[bandIndex], rawMap, rawScale, displayMap, displayScale, peak: { index: rawPeak.index, position: scanIndexToPosition(rawPeak.index, experiment.scan), rawValue: rawPeak.value, rawScale, ...(rawScale === "power" ? { rawPower: rawPeak.value } : { rawScore: rawPeak.value }), }, }; }); } async function computeDasMapInternal(options, analysisPlan) { const { channels, sampleRate, arrayDefinition, scene, experiment, signal, onProgress, maximumWorkingBytes = 512 * 1024 * 1024, maximumComplexMacs = 1_000_000_000, maximumFftButterflies = 1_000_000_000, maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER, pointBatchSize = 64, stftTransformBatchSize = 32, phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR, } = options; const algorithm = analysisPlan?.algorithm ?? requireLocalizationAlgorithmDefinition( experiment.beamforming?.algorithm ?? "das", ); if (!isDasAlgorithm(algorithm.id)) { throw new RangeError(`computeDasMap does not implement ${algorithm.id}.`); } if (channels.length !== arrayDefinition.microphones.length) { throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`); } if (!Number.isInteger(pointBatchSize) || pointBatchSize < 1) throw new Error("pointBatchSize는 1 이상의 정수여야 합니다."); const workload = estimateDasWorkload({ channels, sampleRate, arrayDefinition, experiment, analysisPlan, }); if (workload.complexMacCount > maximumComplexMacs) { throw new Error( `CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` + `현재 한도 ${maximumComplexMacs.toLocaleString()}회를 넘습니다. grid, duration 또는 대역폭을 줄이세요.`, ); } const availableSpectrumBytes = maximumWorkingBytes - workload.baseWorkingBytes; const batchCapacity = Math.floor(availableSpectrumBytes / workload.spectrumBytesPerBin); if (batchCapacity < 1) { throw new Error( `CPU DAS 기본 working set ${(workload.baseWorkingBytes / 1024 ** 2).toFixed(1)} MiB가 ` + `현재 한도 ${(maximumWorkingBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다.`, ); } const batchSize = Math.max( 1, Math.min(workload.selectedBins.length, batchCapacity, Math.max(1, maximumSpectrumBatchBins)), ); const spectrumBatchCount = Math.ceil(workload.selectedBins.length / batchSize); const fftButterflyCount = spectrumBatchCount * workload.frameCount * workload.microphoneCount * (experiment.stft.fftSize / 2) * Math.log2(experiment.stft.fftSize); if (fftButterflyCount > maximumFftButterflies) { throw new Error( `CPU STFT 예상 butterfly ${Math.ceil(fftButterflyCount).toLocaleString()}회가 ` + `현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`, ); } const distances = analysisPlan?.distances ?? createDistanceMatrix(arrayDefinition, experiment.scan); const rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount)); const bandIndexesByBin = analysisPlan?.bandIndexesByBin ?? createBandIndexLookup(workload.bandBins); let completedBins = 0; for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) { throwIfAborted(signal); const batchBins = workload.selectedBins.slice(batchStart, batchStart + batchSize); if (analysisPlan) { analysisPlan.statistics.stftWorkspaceUses += 1; const repeatedUses = Math.max(0, analysisPlan.statistics.stftWorkspaceUses - 1); analysisPlan.statistics.avoidedStftWindowBuilds = repeatedUses; analysisPlan.statistics.avoidedStftWorkspaceAllocations = repeatedUses; } const stft = await extractStftBins(channels, { fftSize: experiment.stft.fftSize, hopSize: experiment.stft.hopSize, bins: batchBins, signal, transformBatchSize: stftTransformBatchSize, workspace: analysisPlan?.stftWorkspace, onProgress: ({ completedTransforms, totalTransforms }) => onProgress?.({ stage: "stft", completedBins, totalBins: workload.selectedBins.length, completedTransforms, totalTransforms, spectrumBatch: Math.floor(batchStart / batchSize) + 1, spectrumBatchCount, }), }); if (analysisPlan) { const completedTransformCount = stft.frameCount * stft.microphoneCount; analysisPlan.statistics.stftTransforms += completedTransformCount; analysisPlan.statistics.fftButterflies += completedTransformCount * (experiment.stft.fftSize / 2) * Math.log2(experiment.stft.fftSize); } await cooperativeYield(); throwIfAborted(signal); for (const bin of batchBins) { const calibration = analysisPlan ? planCalibration(analysisPlan, bin) : undefined; const steeringMatrix = analysisPlan ? await planSteeringMatrix(analysisPlan, bin, calibration, signal, pointBatchSize) : undefined; if (analysisPlan) { analysisPlan.statistics.binMapUses += 1; analysisPlan.statistics.avoidedBinMapAllocations = Math.max( 0, analysisPlan.statistics.binMapUses - 1, ); } const binMap = await computeBinMap({ bin, spectrum: stft.spectra.get(bin), frameCount: stft.frameCount, arrayDefinition, pointCount: workload.pointCount, distances, sampleRate, fftSize: experiment.stft.fftSize, speedOfSound: scene.speedOfSound, compensateFrequencyResponse: experiment.beamforming.compensateFrequencyResponse !== false, maxCompensationDb: experiment.beamforming.maxCompensationDb, calibration, steeringMatrix, output: analysisPlan?.binMap, steeringReal: analysisPlan?.steeringReal, steeringImaginary: analysisPlan?.steeringImaginary, signal, pointBatchSize, algorithm: algorithm.id, phatRelativeFloor, }); if (analysisPlan) { analysisPlan.statistics.beamformingComplexMacs += workload.pointCount * workload.microphoneCount * stft.frameCount; } for (const bandIndex of bandIndexesByBin.get(bin)) { const rawMap = rawMaps[bandIndex]; for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { rawMap[pointIndex] += binMap[pointIndex]; } } completedBins += 1; onProgress?.({ stage: "beamforming", completedBins, totalBins: workload.selectedBins.length }); } } const bands = finalizeDasBands(rawMaps, workload, experiment, algorithm.rawScale); return { algorithm: algorithm.id, backend: "cpu", frameCount: workload.frameCount, microphoneCount: workload.microphoneCount, pointCount: workload.pointCount, workload, fftButterflyCount, bands, ...(analysisPlan ? { analysisCache: analysisCacheSummary(analysisPlan) } : {}), }; } export async function computeDasMap(options) { const analysisPlan = options.analysisPlan; if (!analysisPlan) return computeDasMapInternal(options, undefined); assertCompatibleAnalysisPlan(analysisPlan, options); if (analysisPlan.busy) { throw new Error("analysisPlan is already in use by another DAS analysis."); } analysisPlan.busy = true; analysisPlan.statistics.analysisAttempts += 1; if (analysisPlan.statistics.analysisAttempts > 1) { analysisPlan.statistics.avoidedDistanceEvaluations += analysisPlan.pointCount * analysisPlan.microphoneCount; } try { const result = await computeDasMapInternal(options, analysisPlan); analysisPlan.statistics.completedAnalyses += 1; result.analysisCache = { ...result.analysisCache, analysisAttempts: analysisPlan.statistics.analysisAttempts, completedAnalyses: analysisPlan.statistics.completedAnalyses, }; return result; } finally { analysisPlan.busy = false; } }