Python 2D графика. Визуализация данных в реальном времени. Matplotlib, PyQTGraph, pyOpenGL, VisPy, …
- вторник, 4 февраля 2025 г. в 00:00:11
На мой взгляд, быстрое преобразование больших массивов цифровых данных в наглядные и доступные для восприятия человеком формы приобрело большую востребованность. Даже двумерные графики, отображаемые на экранах мониторов, все еще продолжают сохранять свою актуальность и популярность в таких разнообразных сферах, как торговля ценными бумагами, технические и научные измерения (осциллограммы) и исследования, а также в узких областях, таких как компьютерные студии звукозаписи (БПФ, эквализация, тюнеры). Контекст данной статьи - это цифровая обработка звука.
Для 8 графических пакетов в статье приведены 8 максимально коротких и простых специфичных для каждого пакета кода на python, отображающий на экране с максимально возможным FPS для данного пакета график sin()+noise.
При разработке 2D графики на Python лимитировать может производительность, особенно если вы стремитесь к высокому количеству кадров в секунду (FPS). В этой статье рассмотрим несколько наиболее популярных графических библиотек, их производительность и возможности достижения высоких значений FPS.
Предварительные данные по информации из интернет источников
Mayavi 3D: Хотя Mayavi в первую очередь предназначен для 3D-визуализации, он может использоваться для 2D-графиков. Однако его производительность для 2D задач может быть ниже, чем у специализированных библиотек.
PyVista: Эта библиотека также ориентирована на 3D, но может использоваться для 2D. PyVista предлагает хорошую производительность, но для 2D задач может быть избыточной.
Matplotlib: Широко используемая библиотека для построения графиков, но не оптимизирована для высоких FPS. Обычно Matplotlib работает на уровне 10-30 FPS, что может быть недостаточно для динамичных приложений.
PyQTGraph: Эта библиотека специально разработана для быстрого отображения графиков и может достигать 50 FPS и выше. PyQTGraph использует OpenGL для рендеринга, что значительно увеличивает производительность.
Plotly: Отлично подходит для интерактивных графиков, но его производительность может быть ограничена при большом количестве данных. FPS обычно ниже 50.
PyGame: Одна из самых популярных библиотек для создания игр на Python. PyGame может достигать 60 FPS и выше, если правильно настроить рендеринг и управление событиями.
Arcade: Современная библиотека для создания 2D-игр, которая также может достигать 60 FPS и выше. Arcade использует OpenGL и предлагает простые инструменты для работы с графикой.
pyOpenGL: Это обертка для OpenGL, которая позволяет создавать высокопроизводительные графические приложения. С правильной оптимизацией можно достичь FPS в 100 и выше.
VisPy: Библиотека для визуализации данных, использующая OpenGL. VisPy может достигать высоких значений FPS, особенно при работе с большими объемами данных.
Bokeh: Хотя Bokeh в первую очередь предназначен для веб-визуализации, его производительность для 2D графиков может быть ограничена, и FPS обычно 10..50.
Для тестирования производительности различных библиотек использовались простые сценарии, которые рисуют sin() + noise() на экране и измеряют FPS. Важно учитывать, что производительность может зависеть от аппаратного обеспечения и настроек системы.
Достижение частоты кадров (FPS) > 30 кадров в секунду вполне осуществимо с использованием популярных библиотек. Однако для достижения FPS >= 60 потребуется обращение к низкоуровневым библиотекам, а также тщательная оптимизация кода.
Важно отметить, что включение вертикальной синхронизации (VSync=On) не всегда доступно, поскольку это зависит от конкретной видеокарты, драйверов и мониторов, включая современные 4K телевизоры. Даже если VSync доступна, не все значения частоты обновления могут быть выбраны произвольно, и не всегда они будут корректно обрабатывать сигналы VSync в графических пакетах. Например, синхронизация может быть доступна на 30, 50 Гц, но не на 60 Гц или 44 Гц и так далее.
MatplotLib
import matplotlib.pyplot as plt
import numpy as np
from PyQt5 import QtWidgets
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import time
class MyApp(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.figure, self.ax = plt.subplots()
self.canvas = FigureCanvas(self.figure)
self.setCentralWidget(self.canvas)
self.x = np.linspace(0, 17, 735)
self.y = np.sin(self.x)
self.line, = self.ax.plot(self.x, self.y)
# Настройка координатной сетки
self.ax.grid(True, linestyle='--', linewidth=0.5, color='gray', alpha=0.7) # Серая пунктирная сетка
self.timer = self.startTimer(16) # ~60 FPS
self.frame_count = 0
self.start_time = time.time()
def timerEvent(self, event):
# Генерация шума с равномерным распределением и амплитудой 20% от амплитуды синуса
noise_amplitude = 0.2 * np.max(np.abs(self.y)) # 20% от амплитуды синуса
noise = noise_amplitude * np.random.uniform(-1, 1, len(self.x)) # Равномерно распределенный шум
# noise = noise_amplitude * np.random.normal(0, 1, len(self.x)) # Нормальный шум
# Обновление данных графика (синус + шум)
self.y = np.sin(self.x + 0.1 * event.timerId()) + noise
self.line.set_ydata(self.y)
self.canvas.draw()
# Подсчет FPS
self.frame_count += 1
elapsed_time = time.time() - self.start_time
if elapsed_time > 1: # Обновляем FPS каждую секунду
fps = self.frame_count / elapsed_time
#print(f"FPS: {fps:.2f}")
# Устанавливаем заголовок с увеличенным, жирным и зеленым шрифтом
self.ax.set_title(f"Matplotlib FPS: {fps:.2f}", fontsize=16, fontweight='bold', color='green')
self.canvas.draw() # Перерисовываем график с новым заголовком
self.frame_count = 0
self.start_time = time.time()
app = QtWidgets.QApplication([])
window = MyApp()
window.show()
app.exec_()
Bokeh
import numpy as np
from bokeh.plotting import figure, show
from bokeh.io import output_notebook
from bokeh.models import Button, CustomJS, ColumnDataSource, Div
from bokeh.layouts import column
# Включаем вывод графиков в Jupyter Notebook (если вы используете его)
output_notebook()
# Генерация данных
def generate_data():
x = np.linspace(0, 10, 200) # 200 точек от 0 до 10
y = np.sin(x) # Значения синуса
noise_amplitude = 0.2 * np.abs(y) # Амплитуда шума (20% от амплитуды синуса)
noise = np.random.normal(0, noise_amplitude) # Нормально распределенный шум
y_noisy = y + noise # Добавление шума к значениям синуса
return x, y, y_noisy
# Создание графика с изменёнными размерами
p = figure(title="Синус с нормально распределенным шумом", x_axis_label='X', y_axis_label='Y',
width=800, height=400) # Увеличение ширины в 1.5 раза и уменьшение высоты в 1.5 раза
# Изначальные данные
x, y, y_noisy = generate_data()
# Создание источника данных
source_noisy = ColumnDataSource(data=dict(x=x, y=y_noisy))
source_original = ColumnDataSource(data=dict(x=x, y=y))
# Добавление линий на график с использованием источников данных
line_noisy = p.line('x', 'y', source=source_noisy, line_width=2, color="navy", alpha=0.7, legend_label="Синус + шум")
line_original = p.line('x', 'y', source=source_original, line_width=2, color="orange", alpha=0.5, legend_label="Синус")
# Создание Div для отображения FPS с изменённым стилем
fps_div = Div(text="FPS: 0", width=100, styles={'font-size': '20px', 'color': 'red', 'font-weight': 'bold'})
# Функция обновления графика и FPS
callback = CustomJS(args=dict(source_noisy=source_noisy, source_original=source_original, fps_div=fps_div), code="""
let fpsCounter = 0;
let lastTime = Date.now();
setInterval(() => {
const currentTime = Date.now();
fpsCounter++;
// Обновление данных графика
const x = [];
const y_noisy = [];
const y_original = [];
for (let i = 0; i < 200; i++) {
x.push(i * 0.05);
const sinValue = Math.sin(x[i]);
const noiseAmplitude = 0.2 * Math.abs(sinValue);
const noise = Math.random() * noiseAmplitude * 2 - noiseAmplitude;
y_noisy.push(sinValue + noise);
y_original.push(sinValue);
}
source_noisy.data['x'] = x;
source_noisy.data['y'] = y_noisy;
source_original.data['x'] = x;
source_original.data['y'] = y_original;
// Обновление FPS в Div
if (currentTime - lastTime >= 1000) {
fps_div.text = `FPS: ${fpsCounter}`;
fpsCounter = 0;
lastTime = currentTime;
}
source_noisy.change.emit();
//source_original.change.emit();
}, 10); // Обновление каждые ~66.67 мс (15 раз в секунду)
""")
# Кнопка обновления
button = Button(label="Обновить")
button.js_on_click(callback)
# Отображение графика и кнопки с FPS Div
layout = column(button, fps_div, p)
show(layout)
OpenGL
import sys
import ctypes
import random
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import QTimer, Qt
from OpenGL.GL import *
import math
# Глобальная переменная для хранения данных сигнала
line_vertices = []
num_points = 1000
# Первоначальная генерация данных
for i in range(num_points):
t = i / (num_points - 1)
x = -math.pi + 2 * math.pi * t
y = 0.1 * math.sin(x * 18)
line_vertices.extend([x, y])
def generate_sine_wave(num_points=1000):
global line_vertices
line_vertices = []
for i in range(num_points):
t = i / (num_points - 1)
x = -math.pi + 2 * math.pi * t
base_signal = 0.3 * math.sin(x * 8)
noise = random.uniform(-0.2, 0.2)
y = base_signal + noise
line_vertices.extend([x, y])
class OpenGLWidget(QOpenGLWidget):
def __init__(self, parent=None):
super(OpenGLWidget, self).__init__(parent)
self.line_vao = None
self.line_vbo = None
self.line_program = None
def initializeGL(self):
self.line_program = QOpenGLShaderProgram()
line_vertex_shader_source = """
#version 330 core
layout(location = 0) in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
"""
line_fragment_shader_source = """
#version 330 core
out vec4 fragColor;
void main() {
fragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
"""
self.line_program.addShaderFromSourceCode(QOpenGLShader.Vertex, line_vertex_shader_source)
self.line_program.addShaderFromSourceCode(QOpenGLShader.Fragment, line_fragment_shader_source)
self.line_program.link()
self.line_vao = QOpenGLVertexArrayObject()
self.line_vao.create()
self.line_vbo = QOpenGLBuffer(QOpenGLBuffer.VertexBuffer)
self.line_vbo.create()
self.update_buffer()
glClearColor(0.0, 0.0, 1.0, 1.0)
def update_buffer(self):
self.line_vao.bind()
self.line_vbo.bind()
line_data = (ctypes.c_float * len(line_vertices))(*line_vertices)
self.line_vbo.allocate(line_data, len(line_vertices) * ctypes.sizeof(ctypes.c_float))
self.line_program.bind()
self.line_program.setAttributeBuffer(0, GL_FLOAT, 0, 2)
self.line_program.enableAttributeArray(0)
self.line_vbo.release()
self.line_vao.release()
def resizeGL(self, width, height):
glViewport(0, 0, width, height)
def paintGL(self):
glClear(GL_COLOR_BUFFER_BIT)
self.line_program.bind()
self.line_vao.bind()
glDrawArrays(GL_LINE_STRIP, 0, len(line_vertices) // 2)
self.line_vao.release()
self.line_program.release()
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.opengl_widget = OpenGLWidget()
self.update_button = QPushButton("Update Graph")
self.timer_button = QPushButton("СТАРТ/СТОП ТАЙМЕР")
# FPS display
self.fps_label = QLabel("FPS: 0")
self.fps_label.setAlignment(Qt.AlignCenter)
self.fps_label.setFixedHeight(50) # Фиксированная высота
self.fps_label.setStyleSheet("""
QLabel {
color: red;
font-weight: bold;
font-size: 20px;
}
""")
# Таймеры
self.render_timer = QTimer()
self.render_timer.setInterval(5) # 10 FPS (100 ms)
self.fps_timer = QTimer()
# Счетчики
self.frame_count = 0
self.current_fps = 0
# Настройка соединений
self.update_button.clicked.connect(self.update_graph)
self.timer_button.clicked.connect(self.toggle_timer)
self.render_timer.timeout.connect(self.update_graph)
self.fps_timer.timeout.connect(self.update_fps)
# Настройка интерфейса
layout = QVBoxLayout()
layout.addWidget(self.opengl_widget)
layout.addWidget(self.update_button)
layout.addWidget(self.timer_button)
layout.addWidget(self.fps_label)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
self.setFixedSize(800, 600)
self.setWindowTitle("Noisy Sine Wave Generator with FPS")
# Запуск FPS таймера
self.fps_timer.start(1000) # Обновление FPS каждую секунду
def update_graph(self):
generate_sine_wave()
self.opengl_widget.update_buffer()
self.opengl_widget.update()
self.frame_count += 1 # Увеличиваем счетчик кадров
def toggle_timer(self):
if self.render_timer.isActive():
self.render_timer.stop()
self.timer_button.setText("СТАРТ ТАЙМЕР")
else:
self.render_timer.start()
self.timer_button.setText("СТОП ТАЙМЕР")
def update_fps(self):
self.current_fps = self.frame_count
self.frame_count = 0 # Сбрасываем счетчик
self.fps_label.setText(f"FPS: {self.current_fps}")
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
%gui qt
import vispy
#vispy.use(app="pyqt5")
import numpy as np
from vispy import app, scene
from vispy.visuals.filters import ShadingFilter
# Создаем холст и view
canvas = scene.SceneCanvas(keys='interactive', size=(800, 600), show=True)
view = canvas.central_widget.add_view()
# Настройка камеры
view.camera = 'panzoom'
view.camera.set_range(x=(-10, 10), y=(-10, 10))
# Создаем линию с начальными точками
num_points = 200
line_data = np.zeros((num_points, 3), dtype=np.float32)
line = scene.Line(line_data, color='cyan', width=3, parent=view.scene)
# Добавляем эффекты
#shading = ShadingFilter(shading='smooth')
#line.attach(shading)
# Параметры анимации
angle = 0.0
speed = 0.05
radius = 8.0
async def main():
global angle, line_data
while True:
# Обновляем позиции точек по спирали
angle += speed
t = np.linspace(0, 4 * np.pi, num_points)
x = radius * np.cos(t + angle) * np.cos(0.5 * angle)
y = radius * np.sin(t + angle) * np.sin(0.5 * angle)
# Обновляем данные линии
line_data[:, 0] = x
line_data[:, 1] = y
line.set_data(line_data)
# Ожидаем следующий кадр
await asyncio.sleep(1/60)
canvas.update()
if __name__ == '__main__':
import asyncio
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
app.run()
import pygame
from pygame.locals import OPENGL
import numpy as np
import time
# Инициализация pygame
pygame.init()
# Параметры окна
WIDTH, HEIGHT = 800, 600
FPS = 60
# Цвета
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
GRAY = (200, 200, 200)
GREEN = (0, 255, 0)
RED = (255, 0, 0)
# Настройка шрифта
font = pygame.font.Font(None, 36)
frame_count = 1
elapsed_time = time.time()
fps = 1
fps_text = font.render(f"Pygame FPS: {fps:.2f}", True, RED)
start_time = time.time()
# Создание окна
screen = pygame.display.set_mode((WIDTH, HEIGHT))
#screen = pygame.display.set_mode((800, 600), pygame.RESIZABLE, vsync=1)
pygame.display.set_caption("Pygame Sin Wave with Noise")
# Параметры графика
x = np.linspace(0, 17, 735)
y = np.sin(x)
noise_amplitude = 0.2 * np.max(np.abs(y))
# Функция для отрисовки сетки
def draw_grid():
for i in range(0, WIDTH, 50):
pygame.draw.line(screen, GRAY, (i, 0), (i, HEIGHT), 1)
for j in range(0, HEIGHT, 50):
pygame.draw.line(screen, GRAY, (0, j), (WIDTH, j), 1)
# Основной цикл
clock = pygame.time.Clock()
frame_count = 0
start_time = time.time()
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# Генерация шума
noise = noise_amplitude * np.random.uniform(-1, 1, len(x))
y = np.sin(x + 0.1 * frame_count) + noise
# Очистка экрана
screen.fill(WHITE)
# Отрисовка сетки
draw_grid()
# Отрисовка графика
points = [(int((x[i] / 17) * WIDTH), int((y[i]*0.7 + 1) * HEIGHT / 2)) for i in range(len(x))]
pygame.draw.lines(screen, BLACK, False, points, 2)
# Подсчет FPS
frame_count += 1
elapsed_time = time.time() - start_time
if elapsed_time > 1:
fps = frame_count / elapsed_time
fps_text = font.render(f"Pygame FPS: {fps:.2f}", True, RED)
#screen.blit(fps_text, (10, 10))
frame_count = 0
start_time = time.time()
screen.blit(fps_text, (10, 10))
# Обновление экрана
pygame.display.flip()
clock.tick(FPS)
pygame.quit()
code
import arcade
import math
import random
# Constants
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
SCREEN_TITLE = "Sin with Random Noise"
AMPLITUDE = 0.2 # Amplitude of the random noise
class MyGame(arcade.Window):
def __init__(self, width, height, title):
super().__init__(width, height, title, vsync=True) #будет 30 или 50 кадров 60 не поддерживает fps становится 350
# Set the background color to white
arcade.set_background_color(arcade.color.WHITE)
# Generate initial points for the sin() function with random noise
self.point_list = []
self.base_sin_points = []
self.generate_sin_with_noise()
# Schedule update function to be called every frame
arcade.schedule(self.update, 1/60) # Update at 60 FPS ##########################################################
# Initialize variables for FPS calculation and display
self.frame_count = 0
self.fps = 0
# Create a text object for displaying FPS
self.fps_text = arcade.Text(
f"FPS: {self.fps}",
x=10,
y=10,
color=arcade.color.RED,
font_size=24,
anchor_x="left",
anchor_y="baseline"
)
# Schedule FPS calculation function to be called every second ####################################################
arcade.schedule(self.calculate_fps, 1)
def generate_sin_with_noise(self):
self.point_list = []
self.base_sin_points = []
for x in range(SCREEN_WIDTH):
base_y = SCREEN_HEIGHT // 2 + 100 * math.sin(2 * math.pi * x / SCREEN_WIDTH) # Base sin function
self.base_sin_points.append((x, base_y))
y = base_y + random.uniform(-AMPLITUDE, AMPLITUDE) * 100 # Add random noise
self.point_list.append((x, y))
def on_draw(self):
self.clear()
# Draw the sin() function with random noise using lines
arcade.draw_line_strip(self.point_list, arcade.color.BLUE, 1)
# Display FPS
self.fps_text.draw()
def update(self, delta_time: float):
# Increment frame count
self.frame_count += 1
# Regenerate the points with new random noise every frame (60 times per second)
for i in range(len(self.base_sin_points)):
base_x, base_y = self.base_sin_points[i]
y = base_y + random.uniform(-AMPLITUDE, AMPLITUDE) * 100 # Add new random noise
self.point_list[i] = (base_x, y)
def calculate_fps(self, delta_time: float):
# Calculate FPS
self.fps = self.frame_count
# Reset frame count
self.frame_count = 0
# Update the FPS text
self.fps_text.text = f"FPS: {self.fps}"
def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.S:
# Regenerate the points with new random noise
for i in range(len(self.base_sin_points)):
base_x, base_y = self.base_sin_points[i]
y = base_y + random.uniform(-AMPLITUDE, AMPLITUDE) * 100 # Add new random noise
self.point_list[i] = (base_x, y)
self.on_draw() # Redraw the updated graph
# Open the window
window = MyGame(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
# Keep the window open until the user closes it
arcade.run()
FPS | Особенности | |
Matplotlib | 11.8 | возможности 8/10, 2D, 3D |
Bokeh | 33 | JS, 2D, возможности 7/10 |
PyOpenGL | 50 | низкоуровневый |
VisPy | 93 | возможности 3/10, 2D. 3D |
PyGame | 60 (VSync=On) | возможности 3/10, 2D |
Arcade | 98 | возможности 3/10, 2D |
Potly | 37 | JS, возможности 7/10 |
PyQTGraph | 70 | возможности 6/10 |
MayAvi / VTK | 90 | возможности3/10(2D)3D(9/10) |
PyVista | 60 (JS, Jupyter/Anaconda) | нестабильный Trame backend |
import pyqtgraph as pg
import numpy as np
app = pg.mkQApp()
main_widget = pg.QtWidgets.QWidget()
layout = pg.QtWidgets.QVBoxLayout(main_widget)
layout.setContentsMargins(0, 0, 0, 0)
fps_label = pg.QtWidgets.QLabel("FPS: 0.0")
fps_label.setStyleSheet("font: bold 20px; color: black; padding: 5px;")
layout.addWidget(fps_label, stretch=0)
plot = pg.PlotWidget()
layout.addWidget(plot, stretch=1)
# Настройки графика
plot.showGrid(x=True, y=True, alpha=0.3) # Светло-серая сетка (alpha=0.3)
plot.getAxis('bottom').setPen(pg.mkPen(color='#888')) # Серые оси
plot.getAxis('left').setPen(pg.mkPen(color='#888')) # Серые оси
x = np.linspace(0, 10, 1000)
y = np.sin(x) + np.random.uniform(-0.2, 0.2, 1000)
curve = plot.plot(x, y, pen=pg.mkPen('g', width=4)) # Зеленый, толщина 4px
last_time = pg.QtCore.QTime.currentTime()
fps_average = 0
def update():
global last_time, fps_average
curve.setData(y=np.sin(x) + np.random.uniform(-0.2, 0.2, 1000))
current_time = pg.QtCore.QTime.currentTime()
elapsed = last_time.msecsTo(current_time)
fps = 1000 / elapsed if elapsed > 0 else 0
fps_average = 0.99 * fps_average + 0.01 * fps
fps_label.setText(f"FPS: {fps_average:.1f}")
last_time = current_time
timer = pg.QtCore.QTimer(); timer.timeout.connect(update); timer.start(10)
main_widget.show()
app.exec()
import numpy as np
from mayavi import mlab
import time
# Создаем фигуру и оси
fig = mlab.figure(size=(800, 600), bgcolor=(1, 1, 1))
# Создаем данные для синусоидальной функции с шумом
x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x) + np.random.normal(0, 0.1, x.shape)
z = np.zeros_like(x)
fps1=1
# Создаем линию
line = mlab.plot3d(x, y, z, color=(1, 0, 0), tube_radius=0.01*3)
# Добавляем текст для отображения FPS
fps_text = mlab.text(0.05, 0.05, "FPS: 0", width=0.2, color=(0, 0, 0))
# Функция для обновления FPS
def update_fps():
global fps1
current_time = time.time()
if hasattr(update_fps, "last_time"):
delta_time = current_time - update_fps.last_time
if delta_time > 0:
fps = 1 / delta_time
else:
fps=fps1
fps1 = 0.99*fps1 + 0.01*fps
fps_text.set(text=f"FPS: {int(fps1)}")
update_fps.last_time = current_time
# Функция для обновления данных линии
@mlab.animate(delay=10)
def update_line():
while True:
# Обновляем данные линии с новым шумом
noise = np.random.normal(0, 0.1, x.shape)
y_new = np.sin(x) + noise
line.mlab_source.set(y=y_new)
update_fps()
yield
# Запускаем анимацию
update_line()
# Запускаем визуализацию
mlab.show()
Выбор библиотеки для 2D графики на Python зависит от ваших требований к производительности и функциональности. Matplotlib медленная. Bokeh и Plotly под капотом имеют JavaScript и богатую инфраструктуру для отрисовки вспомогательных элементов графика (сетка, оси, легенды, шрифты, типы графиков, GUI элементы). Но придется решать задачу передачи данных из контекста python в контекст JavaScript. Для достижения высоких значений FPS разумно использовать подходящие инструменты (MayAvi, PyGame, VisPy). Компромиссное решение - PyQTGraph. С правильным подходом можно достичь впечатляющих результатов в визуализации, высокий FPS и хорошей интерактивности.
P,S, код проверен на Python 3.9 и 3.12, Jupyter Notebook, Anaconda, Windows 11. В случае необходимости есть файл enviroment.yaml. Он позволяет установить все зависимости за несколько минут.
P.P.S все файлы к этой статье в моей "телеге"
Похоже на Mac Intell OS 10.* python 3.12 сможет отображать осциллограммы звукового сигнала в реальном времени.