feat: add audio demodulation and FFT processing, enhance audio input handling and visualization
This commit is contained in:
@@ -6,6 +6,10 @@ project("railwaypagerdemod")
|
|||||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||||
demod.cpp
|
demod.cpp
|
||||||
demod.h
|
demod.h
|
||||||
|
audio_demod.cpp
|
||||||
|
audio_demod.h
|
||||||
|
audio_fft.cpp
|
||||||
|
audio_fft.h
|
||||||
native-lib.cpp
|
native-lib.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/dsp/firfilter.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/dsp/firfilter.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
106
android/app/src/main/cpp/audio_demod.cpp
Normal file
106
android/app/src/main/cpp/audio_demod.cpp
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#include "audio_demod.h"
|
||||||
|
#include "demod.h"
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#define LOG_TAG "AudioDemod"
|
||||||
|
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
#define FINE_CLKT_HI 1.90
|
||||||
|
#define FINE_CLKT_LO 0.32
|
||||||
|
#define SAMPLE_RATE 48000
|
||||||
|
#define BAUD_RATE 1200
|
||||||
|
|
||||||
|
static double watch_ctr = 0.0;
|
||||||
|
static double atb_ctr = 0.0;
|
||||||
|
static double clkt = (double)SAMPLE_RATE / BAUD_RATE;
|
||||||
|
static int last_value = 0;
|
||||||
|
static int preamble_count = 0;
|
||||||
|
static int pocbit = 0;
|
||||||
|
static bool preamble_detected = false;
|
||||||
|
static int crossing_count = 0;
|
||||||
|
static int nSamples = 0;
|
||||||
|
|
||||||
|
static const int AUDIO_THRESHOLD = 128;
|
||||||
|
|
||||||
|
void resetAudioDemod() {
|
||||||
|
watch_ctr = 0.0;
|
||||||
|
atb_ctr = 0.0;
|
||||||
|
clkt = (double)SAMPLE_RATE / BAUD_RATE;
|
||||||
|
last_value = 0;
|
||||||
|
preamble_count = 0;
|
||||||
|
pocbit = 0;
|
||||||
|
preamble_detected = false;
|
||||||
|
crossing_count = 0;
|
||||||
|
nSamples = 0;
|
||||||
|
LOGD("Audio demodulator reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
void processAudioSamples(int16_t *samples, int size) {
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
int audio_value = (samples[i] + 32768) / 256;
|
||||||
|
|
||||||
|
if (audio_value < 0) audio_value = 0;
|
||||||
|
if (audio_value > 255) audio_value = 255;
|
||||||
|
|
||||||
|
int current_bit = (audio_value > AUDIO_THRESHOLD) ? 1 : 0;
|
||||||
|
|
||||||
|
if (current_bit != last_value) {
|
||||||
|
crossing_count++;
|
||||||
|
|
||||||
|
if ((nSamples > 28) && (nSamples < 44)) {
|
||||||
|
preamble_count++;
|
||||||
|
|
||||||
|
if (preamble_count > 50 && !preamble_detected) {
|
||||||
|
preamble_detected = true;
|
||||||
|
pocbit = 0;
|
||||||
|
LOGD("Preamble detected! crossings=%d samples=%d", preamble_count, nSamples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nSamples = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nSamples++;
|
||||||
|
last_value = current_bit;
|
||||||
|
|
||||||
|
watch_ctr += 1.0;
|
||||||
|
|
||||||
|
if (watch_ctr - atb_ctr < 1.0) {
|
||||||
|
int bit = current_bit;
|
||||||
|
|
||||||
|
if (preamble_detected) {
|
||||||
|
processBasebandSample(bit);
|
||||||
|
pocbit++;
|
||||||
|
|
||||||
|
if (pocbit > 1250) {
|
||||||
|
LOGD("POCSAG timeout - no sync after 1250 bits");
|
||||||
|
preamble_detected = false;
|
||||||
|
preamble_count = 0;
|
||||||
|
pocbit = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crossing_count > 0) {
|
||||||
|
double offset = watch_ctr - atb_ctr;
|
||||||
|
|
||||||
|
if (offset > FINE_CLKT_HI) {
|
||||||
|
clkt -= 0.01;
|
||||||
|
if (clkt < (SAMPLE_RATE / BAUD_RATE) * 0.95) {
|
||||||
|
clkt = (SAMPLE_RATE / BAUD_RATE) * 0.95;
|
||||||
|
}
|
||||||
|
} else if (offset < FINE_CLKT_LO) {
|
||||||
|
clkt += 0.01;
|
||||||
|
if (clkt > (SAMPLE_RATE / BAUD_RATE) * 1.05) {
|
||||||
|
clkt = (SAMPLE_RATE / BAUD_RATE) * 1.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crossing_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
atb_ctr += clkt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
android/app/src/main/cpp/audio_demod.h
Normal file
10
android/app/src/main/cpp/audio_demod.h
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#ifndef AUDIO_DEMOD_H
|
||||||
|
#define AUDIO_DEMOD_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
void processAudioSamples(int16_t *samples, int size);
|
||||||
|
|
||||||
|
void resetAudioDemod();
|
||||||
|
|
||||||
|
#endif
|
||||||
117
android/app/src/main/cpp/audio_fft.cpp
Normal file
117
android/app/src/main/cpp/audio_fft.cpp
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#include "audio_fft.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
#define LOG_TAG "AudioFFT"
|
||||||
|
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
AudioFFT::AudioFFT(int fftSize)
|
||||||
|
: fftSize_(fftSize)
|
||||||
|
, inputBuffer_(fftSize, 0.0f)
|
||||||
|
, windowBuffer_(fftSize, 0.0f)
|
||||||
|
, realPart_(fftSize, 0.0f)
|
||||||
|
, imagPart_(fftSize, 0.0f)
|
||||||
|
, magnitude_(fftSize, 0.0f)
|
||||||
|
, bufferPos_(0) {
|
||||||
|
|
||||||
|
for (int i = 0; i < fftSize_; i++) {
|
||||||
|
windowBuffer_[i] = 0.54f - 0.46f * std::cos(2.0f * M_PI * i / (fftSize_ - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGD("AudioFFT initialized with size %d", fftSize_);
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioFFT::~AudioFFT() {
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioFFT::processSamples(const int16_t* samples, int size) {
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
inputBuffer_[bufferPos_] = samples[i] / 32768.0f;
|
||||||
|
bufferPos_++;
|
||||||
|
|
||||||
|
if (bufferPos_ >= fftSize_) {
|
||||||
|
computeFFT();
|
||||||
|
bufferPos_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioFFT::applyWindow() {
|
||||||
|
for (int i = 0; i < fftSize_; i++) {
|
||||||
|
realPart_[i] = inputBuffer_[i] * windowBuffer_[i];
|
||||||
|
imagPart_[i] = 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioFFT::computeFFT() {
|
||||||
|
applyWindow();
|
||||||
|
|
||||||
|
int n = fftSize_;
|
||||||
|
|
||||||
|
int j = 0;
|
||||||
|
for (int i = 0; i < n - 1; i++) {
|
||||||
|
if (i < j) {
|
||||||
|
std::swap(realPart_[i], realPart_[j]);
|
||||||
|
std::swap(imagPart_[i], imagPart_[j]);
|
||||||
|
}
|
||||||
|
int k = n / 2;
|
||||||
|
while (k <= j) {
|
||||||
|
j -= k;
|
||||||
|
k /= 2;
|
||||||
|
}
|
||||||
|
j += k;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int len = 2; len <= n; len *= 2) {
|
||||||
|
float angle = -2.0f * M_PI / len;
|
||||||
|
float wlenReal = std::cos(angle);
|
||||||
|
float wlenImag = std::sin(angle);
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i += len) {
|
||||||
|
float wReal = 1.0f;
|
||||||
|
float wImag = 0.0f;
|
||||||
|
|
||||||
|
for (int k = 0; k < len / 2; k++) {
|
||||||
|
int idx1 = i + k;
|
||||||
|
int idx2 = i + k + len / 2;
|
||||||
|
|
||||||
|
float tReal = wReal * realPart_[idx2] - wImag * imagPart_[idx2];
|
||||||
|
float tImag = wReal * imagPart_[idx2] + wImag * realPart_[idx2];
|
||||||
|
|
||||||
|
realPart_[idx2] = realPart_[idx1] - tReal;
|
||||||
|
imagPart_[idx2] = imagPart_[idx1] - tImag;
|
||||||
|
realPart_[idx1] += tReal;
|
||||||
|
imagPart_[idx1] += tImag;
|
||||||
|
|
||||||
|
float wTempReal = wReal * wlenReal - wImag * wlenImag;
|
||||||
|
wImag = wReal * wlenImag + wImag * wlenReal;
|
||||||
|
wReal = wTempReal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < fftSize_; i++) {
|
||||||
|
float real = realPart_[i];
|
||||||
|
float imag = imagPart_[i];
|
||||||
|
magnitude_[i] = std::sqrt(real * real + imag * imag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AudioFFT::getSpectrum(float* output, int outputSize) {
|
||||||
|
int copySize = std::min(outputSize, fftSize_ / 2);
|
||||||
|
|
||||||
|
for (int i = 0; i < copySize; i++) {
|
||||||
|
float mag = magnitude_[i];
|
||||||
|
if (mag < 1e-10f) mag = 1e-10f;
|
||||||
|
|
||||||
|
float db = 20.0f * std::log10(mag);
|
||||||
|
|
||||||
|
float normalized = (db + 80.0f) / 80.0f;
|
||||||
|
output[i] = std::max(0.0f, std::min(1.0f, normalized));
|
||||||
|
}
|
||||||
|
}
|
||||||
29
android/app/src/main/cpp/audio_fft.h
Normal file
29
android/app/src/main/cpp/audio_fft.h
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#ifndef AUDIO_FFT_H
|
||||||
|
#define AUDIO_FFT_H
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class AudioFFT {
|
||||||
|
public:
|
||||||
|
AudioFFT(int fftSize = 256);
|
||||||
|
~AudioFFT();
|
||||||
|
|
||||||
|
void processSamples(const int16_t* samples, int size);
|
||||||
|
void getSpectrum(float* output, int outputSize);
|
||||||
|
int getFFTSize() const { return fftSize_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void computeFFT();
|
||||||
|
void applyWindow();
|
||||||
|
|
||||||
|
int fftSize_;
|
||||||
|
std::vector<float> inputBuffer_;
|
||||||
|
std::vector<float> windowBuffer_;
|
||||||
|
std::vector<float> realPart_;
|
||||||
|
std::vector<float> imagPart_;
|
||||||
|
std::vector<float> magnitude_;
|
||||||
|
int bufferPos_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
#include <android/log.h>
|
#include <android/log.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include "demod.h"
|
#include "demod.h"
|
||||||
|
#include "audio_demod.h"
|
||||||
|
#include "audio_fft.h"
|
||||||
|
|
||||||
#define BUF_SIZE 8192
|
#define BUF_SIZE 8192
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ static std::mutex msgMutex;
|
|||||||
static std::vector<std::string> messageBuffer;
|
static std::vector<std::string> messageBuffer;
|
||||||
|
|
||||||
static std::mutex demodDataMutex;
|
static std::mutex demodDataMutex;
|
||||||
|
static std::mutex fftMutex;
|
||||||
|
static AudioFFT* audioFFT = nullptr;
|
||||||
|
|
||||||
static JavaVM *g_vm = nullptr;
|
static JavaVM *g_vm = nullptr;
|
||||||
static jobject g_obj = nullptr;
|
static jobject g_obj = nullptr;
|
||||||
@@ -95,20 +99,36 @@ Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_nativePushAudio(
|
|||||||
|
|
||||||
jshort *samples = env->GetShortArrayElements(audioData, NULL);
|
jshort *samples = env->GetShortArrayElements(audioData, NULL);
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> fftLock(fftMutex);
|
||||||
|
if (!audioFFT) {
|
||||||
|
audioFFT = new AudioFFT(4096);
|
||||||
|
}
|
||||||
|
audioFFT->processSamples(samples, size);
|
||||||
|
}
|
||||||
|
|
||||||
std::lock_guard<std::mutex> demodLock(demodDataMutex);
|
std::lock_guard<std::mutex> demodLock(demodDataMutex);
|
||||||
|
|
||||||
for (int i = 0; i < size; i++) {
|
processAudioSamples(samples, size);
|
||||||
double sample = (double)samples[i] / 32768.0;
|
|
||||||
processBasebandSample(sample);
|
|
||||||
}
|
|
||||||
|
|
||||||
env->ReleaseShortArrayElements(audioData, samples, 0);
|
|
||||||
|
|
||||||
if (is_message_ready) {
|
if (is_message_ready) {
|
||||||
std::ostringstream ss;
|
std::ostringstream ss;
|
||||||
std::lock_guard<std::mutex> msgLock(msgMutex);
|
std::lock_guard<std::mutex> msgLock(msgMutex);
|
||||||
|
|
||||||
std::string message_content = alpha_msg.empty() ? numeric_msg : alpha_msg;
|
std::string message_content;
|
||||||
|
if (function_bits == 3) {
|
||||||
|
message_content = alpha_msg;
|
||||||
|
} else {
|
||||||
|
message_content = numeric_msg;
|
||||||
|
}
|
||||||
|
if (message_content.empty()) {
|
||||||
|
message_content = alpha_msg.empty() ? numeric_msg : alpha_msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
__android_log_print(ANDROID_LOG_DEBUG, "AUDIO",
|
||||||
|
"msg_ready: addr=%u func=%d alpha_len=%zu numeric_len=%zu",
|
||||||
|
address, function_bits, alpha_msg.length(), numeric_msg.length());
|
||||||
|
|
||||||
ss << "[MSG]" << address << "|" << function_bits << "|" << message_content;
|
ss << "[MSG]" << address << "|" << function_bits << "|" << message_content;
|
||||||
messageBuffer.push_back(ss.str());
|
messageBuffer.push_back(ss.str());
|
||||||
|
|
||||||
@@ -116,6 +136,8 @@ Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_nativePushAudio(
|
|||||||
numeric_msg.clear();
|
numeric_msg.clear();
|
||||||
alpha_msg.clear();
|
alpha_msg.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
env->ReleaseShortArrayElements(audioData, samples, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jdouble JNICALL
|
extern "C" JNIEXPORT jdouble JNICALL
|
||||||
@@ -135,6 +157,32 @@ Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_clearMessageBuffer(JNIEnv
|
|||||||
alpha_msg.clear();
|
alpha_msg.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||||
|
Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_getAudioSpectrum(JNIEnv *env, jobject)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> fftLock(fftMutex);
|
||||||
|
|
||||||
|
if (!audioFFT) {
|
||||||
|
return env->NewFloatArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int spectrumSize = audioFFT->getFFTSize() / 2;
|
||||||
|
std::vector<float> spectrum(spectrumSize);
|
||||||
|
audioFFT->getSpectrum(spectrum.data(), spectrumSize);
|
||||||
|
|
||||||
|
const int outputBins = 500;
|
||||||
|
std::vector<float> downsampled(outputBins);
|
||||||
|
|
||||||
|
for (int i = 0; i < outputBins; i++) {
|
||||||
|
int srcIdx = (i * spectrumSize) / outputBins;
|
||||||
|
downsampled[i] = spectrum[srcIdx];
|
||||||
|
}
|
||||||
|
|
||||||
|
jfloatArray result = env->NewFloatArray(outputBins);
|
||||||
|
env->SetFloatArrayRegion(result, 0, outputBins, downsampled.data());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jbyteArray JNICALL
|
extern "C" JNIEXPORT jbyteArray JNICALL
|
||||||
Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_pollMessages(JNIEnv *env, jobject)
|
Java_org_noxylva_lbjconsole_flutter_AudioInputHandler_pollMessages(JNIEnv *env, jobject)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class AudioInputHandler(private val context: Context) : MethodChannel.MethodCall
|
|||||||
private external fun nativePushAudio(data: ShortArray, size: Int)
|
private external fun nativePushAudio(data: ShortArray, size: Int)
|
||||||
private external fun pollMessages(): ByteArray
|
private external fun pollMessages(): ByteArray
|
||||||
private external fun clearMessageBuffer()
|
private external fun clearMessageBuffer()
|
||||||
|
private external fun getAudioSpectrum(): FloatArray
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
@@ -69,6 +70,14 @@ class AudioInputHandler(private val context: Context) : MethodChannel.MethodCall
|
|||||||
clearMessageBuffer()
|
clearMessageBuffer()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"getSpectrum" -> {
|
||||||
|
try {
|
||||||
|
val spectrum = getAudioSpectrum()
|
||||||
|
result.success(spectrum.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("FFT_ERROR", "Failed to get spectrum", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:lbjconsole/services/background_service.dart';
|
|||||||
import 'package:lbjconsole/services/rtl_tcp_service.dart';
|
import 'package:lbjconsole/services/rtl_tcp_service.dart';
|
||||||
import 'package:lbjconsole/services/audio_input_service.dart';
|
import 'package:lbjconsole/services/audio_input_service.dart';
|
||||||
import 'package:lbjconsole/themes/app_theme.dart';
|
import 'package:lbjconsole/themes/app_theme.dart';
|
||||||
|
import 'package:lbjconsole/widgets/audio_waterfall_widget.dart';
|
||||||
|
|
||||||
class _ConnectionStatusWidget extends StatefulWidget {
|
class _ConnectionStatusWidget extends StatefulWidget {
|
||||||
final BLEService bleService;
|
final BLEService bleService;
|
||||||
@@ -839,16 +840,8 @@ class _PixelPerfectBluetoothDialogState
|
|||||||
|
|
||||||
Widget _buildAudioInputView(BuildContext context) {
|
Widget _buildAudioInputView(BuildContext context) {
|
||||||
return Column(mainAxisSize: MainAxisSize.min, children: [
|
return Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
const Icon(Icons.mic, size: 48, color: Colors.blue),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('监听中',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.titleMedium
|
|
||||||
?.copyWith(fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text("请使用音频线连接设备",
|
const AudioWaterfallWidget(),
|
||||||
style: TextStyle(color: Colors.grey)),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
234
lib/widgets/audio_waterfall_widget.dart
Normal file
234
lib/widgets/audio_waterfall_widget.dart
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class _WaterfallCache {
|
||||||
|
static final _WaterfallCache instance = _WaterfallCache._();
|
||||||
|
_WaterfallCache._();
|
||||||
|
|
||||||
|
static const int waterfallWidth = 500;
|
||||||
|
static const int waterfallHeight = 500;
|
||||||
|
|
||||||
|
Uint32List? pixels;
|
||||||
|
late List<int> colorLUT;
|
||||||
|
|
||||||
|
void initialize() {
|
||||||
|
if (pixels == null) {
|
||||||
|
pixels = Uint32List(waterfallWidth * waterfallHeight);
|
||||||
|
for (int i = 0; i < pixels!.length; i++) {
|
||||||
|
pixels![i] = 0xFF000033;
|
||||||
|
}
|
||||||
|
_buildColorLUT();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _buildColorLUT() {
|
||||||
|
colorLUT = List.filled(256, 0);
|
||||||
|
for (int i = 0; i < 256; i++) {
|
||||||
|
final intensity = i / 255.0;
|
||||||
|
final color = _intensityToColor(intensity);
|
||||||
|
colorLUT[i] = (color.alpha << 24) |
|
||||||
|
(color.red << 16) |
|
||||||
|
(color.green << 8) |
|
||||||
|
color.blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _intensityToColor(double intensity) {
|
||||||
|
if (intensity < 0.2) {
|
||||||
|
return Color.lerp(
|
||||||
|
const Color(0xFF000033), const Color(0xFF0000FF), intensity / 0.2)!;
|
||||||
|
} else if (intensity < 0.4) {
|
||||||
|
return Color.lerp(const Color(0xFF0000FF), const Color(0xFF00FFFF),
|
||||||
|
(intensity - 0.2) / 0.2)!;
|
||||||
|
} else if (intensity < 0.6) {
|
||||||
|
return Color.lerp(const Color(0xFF00FFFF), const Color(0xFF00FF00),
|
||||||
|
(intensity - 0.4) / 0.2)!;
|
||||||
|
} else if (intensity < 0.8) {
|
||||||
|
return Color.lerp(const Color(0xFF00FF00), const Color(0xFFFFFF00),
|
||||||
|
(intensity - 0.6) / 0.2)!;
|
||||||
|
} else {
|
||||||
|
return Color.lerp(const Color(0xFFFFFF00), const Color(0xFFFF0000),
|
||||||
|
(intensity - 0.8) / 0.2)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioWaterfallWidget extends StatefulWidget {
|
||||||
|
const AudioWaterfallWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AudioWaterfallWidget> createState() => _AudioWaterfallWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioWaterfallWidgetState extends State<AudioWaterfallWidget> {
|
||||||
|
static const platform = MethodChannel('org.noxylva.lbjconsole/audio_input');
|
||||||
|
|
||||||
|
final _cache = _WaterfallCache.instance;
|
||||||
|
ui.Image? _waterfallImage;
|
||||||
|
List<double> _currentSpectrum = [];
|
||||||
|
|
||||||
|
Timer? _updateTimer;
|
||||||
|
bool _imageNeedsUpdate = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cache.initialize();
|
||||||
|
_startUpdating();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_updateTimer?.cancel();
|
||||||
|
_waterfallImage?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startUpdating() {
|
||||||
|
_updateTimer =
|
||||||
|
Timer.periodic(const Duration(milliseconds: 20), (timer) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getSpectrum');
|
||||||
|
if (result != null && result is List && mounted) {
|
||||||
|
final fftData = result.cast<double>();
|
||||||
|
|
||||||
|
final pixels = _cache.pixels!;
|
||||||
|
pixels.setRange(
|
||||||
|
_WaterfallCache.waterfallWidth, pixels.length, pixels, 0);
|
||||||
|
|
||||||
|
for (int i = 0;
|
||||||
|
i < _WaterfallCache.waterfallWidth && i < fftData.length;
|
||||||
|
i++) {
|
||||||
|
final intensity = (fftData[i].clamp(0.0, 1.0) * 255).toInt();
|
||||||
|
pixels[i] = _cache.colorLUT[intensity];
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentSpectrum = fftData;
|
||||||
|
_imageNeedsUpdate = true;
|
||||||
|
|
||||||
|
if (_imageNeedsUpdate) {
|
||||||
|
_rebuildImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _rebuildImage() async {
|
||||||
|
_imageNeedsUpdate = false;
|
||||||
|
|
||||||
|
final completer = Completer<ui.Image>();
|
||||||
|
ui.decodeImageFromPixels(
|
||||||
|
_cache.pixels!.buffer.asUint8List(),
|
||||||
|
_WaterfallCache.waterfallWidth,
|
||||||
|
_WaterfallCache.waterfallHeight,
|
||||||
|
ui.PixelFormat.bgra8888,
|
||||||
|
(image) => completer.complete(image),
|
||||||
|
);
|
||||||
|
|
||||||
|
final newImage = await completer.future;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_waterfallImage?.dispose();
|
||||||
|
_waterfallImage = newImage;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
newImage.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 80,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(color: Colors.cyan.withOpacity(0.3), width: 2),
|
||||||
|
right: BorderSide(color: Colors.cyan.withOpacity(0.3), width: 2),
|
||||||
|
top: BorderSide(color: Colors.cyan.withOpacity(0.3), width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _currentSpectrum.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.cyan, strokeWidth: 2))
|
||||||
|
: CustomPaint(painter: _SpectrumPainter(_currentSpectrum)),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 100,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(color: Colors.cyan.withOpacity(0.3), width: 2),
|
||||||
|
right: BorderSide(color: Colors.cyan.withOpacity(0.3), width: 2),
|
||||||
|
bottom: BorderSide(color: Colors.cyan.withOpacity(0.3), width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _waterfallImage == null
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.cyan))
|
||||||
|
: CustomPaint(painter: _WaterfallImagePainter(_waterfallImage!)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpectrumPainter extends CustomPainter {
|
||||||
|
final List<double> spectrum;
|
||||||
|
_SpectrumPainter(this.spectrum);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (spectrum.isEmpty) return;
|
||||||
|
|
||||||
|
final path = Path();
|
||||||
|
final binWidth = size.width / spectrum.length;
|
||||||
|
|
||||||
|
for (int i = 0; i < spectrum.length; i++) {
|
||||||
|
final x = i * binWidth;
|
||||||
|
final y = size.height - (spectrum[i].clamp(0.0, 1.0) * size.height);
|
||||||
|
i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.cyan
|
||||||
|
..strokeWidth = 0.5
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..isAntiAlias = true
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_SpectrumPainter old) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaterfallImagePainter extends CustomPainter {
|
||||||
|
final ui.Image image;
|
||||||
|
_WaterfallImagePainter(this.image);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
canvas.drawImageRect(
|
||||||
|
image,
|
||||||
|
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||||
|
Rect.fromLTWH(0, 0, size.width, size.height),
|
||||||
|
Paint()..filterQuality = FilterQuality.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_WaterfallImagePainter old) => old.image != image;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user