/* * Copyright (c) 2024, Noah Bright * Copyright (c) 2025, Tim Ledbetter * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include #include #include #include #include #include namespace Web::WebAudio { GC_DEFINE_ALLOCATOR(AnalyserNode); AnalyserNode::AnalyserNode(JS::Realm& realm, GC::Ref context, AnalyserOptions const& options) : AudioNode(realm, context) , m_fft_size(options.fft_size) , m_max_decibels(options.max_decibels) , m_min_decibels(options.min_decibels) , m_smoothing_time_constant(options.smoothing_time_constant) { } AnalyserNode::~AnalyserNode() = default; WebIDL::ExceptionOr> AnalyserNode::create(JS::Realm& realm, GC::Ref context, AnalyserOptions const& options) { return construct_impl(realm, context, options); } // https://webaudio.github.io/web-audio-api/#current-time-domain-data Vector AnalyserNode::current_time_domain_data() { dbgln("FIXME: Analyser node: implement current time domain data"); // The input signal must be down-mixed to mono as if channelCount is 1, channelCountMode is "max" and channelInterpretation is "speakers". // This is independent of the settings for the AnalyserNode itself. // The most recent fftSize frames are used for the down-mixing operation. // FIXME: definition of "input signal" above unclear // need to implement up/down mixing somewhere // https://webaudio.github.io/web-audio-api/#channel-up-mixing-and-down-mixing Vector result; result.resize(m_fft_size); return result; } // https://webaudio.github.io/web-audio-api/#blackman-window Vector AnalyserNode::apply_a_blackman_window(Vector const& x) const { f32 const a = 0.16; f32 const a0 = 0.5f * (1 - a); f32 const a1 = 0.5; f32 const a2 = a * 0.5f; unsigned long const N = m_fft_size; auto w = [&](unsigned long n) { return a0 - a1 * cos(2 * AK::Pi * (f32)n / (f32)N) + a2 * cos(4 * AK::Pi * (f32)n / (f32)N); }; Vector x_hat; x_hat.resize(m_fft_size); // FIXME: Naive for (unsigned long i = 0; i < m_fft_size; i++) { x_hat[i] = x[i] * w(i); }; return x_hat; } // https://webaudio.github.io/web-audio-api/#fourier-transform static Vector apply_a_fourier_transform(Vector const& input) { dbgln("FIXME: Analyser node: implement apply a fourier transform"); auto result = Vector(); result.resize(input.size()); return result; } // https://webaudio.github.io/web-audio-api/#smoothing-over-time Vector AnalyserNode::smoothing_over_time(Vector const& current_block) { auto X = apply_a_fourier_transform(current_block); // FIXME: Naive Vector result; result.ensure_capacity(m_fft_size); for (unsigned long i = 0; i < m_fft_size; i++) { // FIMXE: Complex modulus on X[i] result.unchecked_append(m_smoothing_time_constant * m_previous_block[i] + (1.f - m_smoothing_time_constant) * abs(X[i])); } m_previous_block = result; return result; } // https://webaudio.github.io/web-audio-api/#conversion-to-db Vector AnalyserNode::conversion_to_dB(Vector const& X_hat) const { Vector result; result.ensure_capacity(X_hat.size()); // FIXME: Naive for (auto x : X_hat) result.unchecked_append(20.0f * AK::log(x)); return result; } // https://webaudio.github.io/web-audio-api/#current-frequency-data Vector AnalyserNode::current_frequency_data() { // 1. Compute the current time-domain data. auto current_time_domain_dat = current_time_domain_data(); // 2. Apply a Blackman window to the time domain input data. auto blackman_windowed_input = apply_a_blackman_window(current_time_domain_dat); // 3. Apply a Fourier transform to the windowed time domain input data to get real and imaginary frequency data. auto frequency_domain_dat = apply_a_fourier_transform(blackman_windowed_input); // 4. Smooth over time the frequency domain data. auto smoothed_data = smoothing_over_time(frequency_domain_dat); // 5. Convert to dB. return conversion_to_dB(smoothed_data); } // https://webaudio.github.io/web-audio-api/#dom-analysernode-getfloatfrequencydata WebIDL::ExceptionOr AnalyserNode::get_float_frequency_data(GC::Root const& array) { // Write the current frequency data into array. If array has fewer elements than the frequencyBinCount, // the excess elements will be dropped. If array has more elements than the frequencyBinCount, the // excess elements will be ignored. The most recent fftSize frames are used in computing the frequency data. auto const frequency_data = current_frequency_data(); // FIXME: If another call to getFloatFrequencyData() or getByteFrequencyData() occurs within the same render // quantum as a previous call, the current frequency data is not updated with the same data. Instead, the // previously computed data is returned. auto& vm = this->vm(); if (!is(*array->raw_object())) return vm.throw_completion(JS::ErrorType::NotAnObjectOfType, "Float32Array"); auto& output_array = static_cast(*array->raw_object()); size_t floats_to_write = min(output_array.data().size(), frequency_bin_count()); for (size_t i = 0; i < floats_to_write; i++) { output_array.data()[i] = frequency_data[i]; } return {}; } // https://webaudio.github.io/web-audio-api/#dom-analysernode-getbytefrequencydata WebIDL::ExceptionOr AnalyserNode::get_byte_frequency_data(GC::Root const& array) { // FIXME: If another call to getByteFrequencyData() or getFloatFrequencyData() occurs within the same render // quantum as a previous call, the current frequency data is not updated with the same data. Instead, // the previously computed data is returned. // Need to implement some kind of blocking mechanism, I guess // Might be more obvious how to handle this when render quantua have some // more scaffolding // // current_frequency_data returns a vector of size m_fftSize // FIXME: Ensure sizes are correct after the fourier transform is implemented // Spec says to write frequencyBinCount bytes, not fftSize Vector dB_data = current_frequency_data(); Vector byte_data; byte_data.ensure_capacity(dB_data.size()); // For getByteFrequencyData(), the 𝑌[𝑘] is clipped to lie between minDecibels and maxDecibels // and then scaled to fit in an unsigned byte such that minDecibels is represented by the // value 0 and maxDecibels is represented by the value 255. // FIXME: Naive f32 delta_dB = m_max_decibels - m_min_decibels; for (auto x : dB_data) { x = max(x, m_min_decibels); x = min(x, m_max_decibels); byte_data.unchecked_append(static_cast(255 * (x - m_min_decibels) / delta_dB)); } // Write the current frequency data into array. If array’s byte length is less than frequencyBinCount, // the excess elements will be dropped. If array’s byte length is greater than the frequencyBinCount , // the excess elements will be ignored. The most recent fftSize frames are used in computing the frequency data. auto& output_buffer = array->viewed_array_buffer()->buffer(); size_t bytes_to_write = min(array->byte_length(), frequency_bin_count()); for (size_t i = 0; i < bytes_to_write; i++) output_buffer[i] = byte_data[i]; return {}; } // https://webaudio.github.io/web-audio-api/#dom-analysernode-getfloattimedomaindata WebIDL::ExceptionOr AnalyserNode::get_float_time_domain_data(GC::Root const& array) { // Write the current time-domain data (waveform data) into array. If array has fewer elements than the // value of fftSize, the excess elements will be dropped. If array has more elements than the value of // fftSize, the excess elements will be ignored. The most recent fftSize frames are written (after downmixing). Vector time_domain_data = current_time_domain_data(); auto& vm = this->vm(); if (!is(*array->raw_object())) return vm.throw_completion(JS::ErrorType::NotAnObjectOfType, "Float32Array"); auto& output_array = static_cast(*array->raw_object()); size_t floats_to_write = min(output_array.data().size(), frequency_bin_count()); for (size_t i = 0; i < floats_to_write; i++) { output_array.data()[i] = time_domain_data[i]; } return {}; } // https://webaudio.github.io/web-audio-api/#dom-analysernode-getbytetimedomaindata WebIDL::ExceptionOr AnalyserNode::get_byte_time_domain_data(GC::Root const& array) { // Write the current time-domain data (waveform data) into array. If array’s byte length is less than // fftSize, the excess elements will be dropped. If array’s byte length is greater than the fftSize, // the excess elements will be ignored. The most recent fftSize frames are used in computing the byte data. Vector time_domain_data = current_time_domain_data(); VERIFY(time_domain_data.size() == m_fft_size); Vector byte_data; byte_data.ensure_capacity(m_fft_size); // FIXME: Naive for (size_t i = 0; i < m_fft_size; i++) { auto x = 128 * (1 + time_domain_data[i]); x = max(x, 0); x = min(x, 255); byte_data.unchecked_append(static_cast(x)); } auto& output_buffer = array->viewed_array_buffer()->buffer(); size_t bytes_to_write = min(array->byte_length(), fft_size()); for (size_t i = 0; i < bytes_to_write; i++) output_buffer[i] = byte_data[i]; return {}; } // https://webaudio.github.io/web-audio-api/#dom-analysernode-fftsize WebIDL::ExceptionOr AnalyserNode::set_fft_size(unsigned long fft_size) { if (fft_size < 32 || fft_size > 32768 || !is_power_of_two(fft_size)) return WebIDL::IndexSizeError::create(realm(), "Analyser node fftSize not a power of 2 between 32 and 32768"_string); // reset previous block to 0s m_previous_block = Vector(); m_previous_block.resize(fft_size); m_fft_size = fft_size; // FIXME: Check this: // Note that increasing fftSize does mean that the current time-domain data must be expanded // to include past frames that it previously did not. This means that the AnalyserNode // effectively MUST keep around the last 32768 sample-frames and the current time-domain // data is the most recent fftSize sample-frames out of that. return {}; } WebIDL::ExceptionOr AnalyserNode::set_max_decibels(double max_decibels) { if (m_min_decibels >= max_decibels) return WebIDL::IndexSizeError::create(realm(), "Analyser node minDecibels greater than maxDecibels"_string); m_max_decibels = max_decibels; return {}; } WebIDL::ExceptionOr AnalyserNode::set_min_decibels(double min_decibels) { if (min_decibels >= m_max_decibels) return WebIDL::IndexSizeError::create(realm(), "Analyser node minDecibels greater than maxDecibels"_string); m_min_decibels = min_decibels; return {}; } WebIDL::ExceptionOr AnalyserNode::set_smoothing_time_constant(double smoothing_time_constant) { if (smoothing_time_constant > 1.0 || smoothing_time_constant < 0.0) return WebIDL::IndexSizeError::create(realm(), "Analyser node smoothingTimeConstant not between 0.0 and 1.0"_string); m_smoothing_time_constant = smoothing_time_constant; return {}; } WebIDL::ExceptionOr> AnalyserNode::construct_impl(JS::Realm& realm, GC::Ref context, AnalyserOptions const& options) { if (options.min_decibels >= options.max_decibels) return WebIDL::IndexSizeError::create(realm, "Analyser node minDecibels greater than maxDecibels"_string); if (options.smoothing_time_constant > 1.0 || options.smoothing_time_constant < 0.0) return WebIDL::IndexSizeError::create(realm, "Analyser node smoothingTimeConstant not between 0.0 and 1.0"_string); // When the constructor is called with a BaseAudioContext c and an option object option, the user agent // MUST initialize the AudioNode this, with context and options as arguments. auto node = realm.create(realm, context, options); TRY(node->set_fft_size(options.fft_size)); // Default options for channel count and interpretation // https://webaudio.github.io/web-audio-api/#AnalyserNode AudioNodeDefaultOptions default_options; default_options.channel_count_mode = Bindings::ChannelCountMode::Max; default_options.channel_interpretation = Bindings::ChannelInterpretation::Speakers; default_options.channel_count = 2; // FIXME: Set tail-time to no TRY(node->initialize_audio_node_options(options, default_options)); return node; } void AnalyserNode::initialize(JS::Realm& realm) { Base::initialize(realm); WEB_SET_PROTOTYPE_FOR_INTERFACE(AnalyserNode); } }