Last active
January 18, 2024 05:53
-
-
Save nathanielatom/6442f94faca69dcf6efae146cb66c30c to your computer and use it in GitHub Desktop.
Custom Bokeh Model for audio playback (along with a sample plot that has a playback span cursor).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# encoding: utf-8 | |
import bokeh.models as bkm | |
import bokeh.core as bkc | |
from bokeh.util.compiler import JavaScript | |
class AudioPlayerModel(bkm.layouts.Column): | |
""" | |
Audio player using https://howlerjs.com/. | |
.. todo:: debug @audio.on('load', () => @model.seek_bar.end = @audio.duration()) not firing when audio | |
is already loaded (same file played in multiple instances on the same webpage) ... maybe once('play', ...) | |
""" | |
__javascript__ = ["https://cdnjs.cloudflare.com/ajax/libs/howler/2.0.13/howler.core.min.js", | |
"https://cdnjs.cloudflare.com/ajax/libs/downloadjs/1.4.7/download.min.js"] | |
JS = """ | |
import * as p from "core/properties"; | |
import {Column, ColumnView} from "models/layouts/column"; | |
export class AudioPlayerView extends ColumnView { | |
constructor(...args) { | |
super(...args); | |
this.play = this.play.bind(this); | |
this.pause = this.pause.bind(this); | |
this.stop = this.stop.bind(this); | |
this.play_pause_press = this.play_pause_press.bind(this); | |
this.finish_drag = this.finish_drag.bind(this); | |
this.seek = this.seek.bind(this); | |
this.update_seek_bar = this.update_seek_bar.bind(this); | |
this.step = this.step.bind(this); | |
} | |
initialize(options) { | |
super.initialize(options); | |
this.audio = new Howl({src: [this.model.audio_source]}); | |
this.audio.on('play', () => { this.model.play_pause_button.label = '❚❚'; }); | |
this.audio.on('pause', () => { this.model.play_pause_button.label = '►'; }); | |
this.audio.on('stop', () => { this.model.play_pause_button.label = '►'; this.model.play_pause_button.active = false; }); | |
this.audio.on('load', () => { this.model.seek_bar.end = this.audio.duration(); }); | |
this.audio.on('end', () => this.stop()); | |
this.audio_mime_type = this.model.audio_source.includes(';base64,') ? this.model.audio_source.split(';base64,')[0].split(':')[1] : "text/plain"; | |
this.audio_ext = this.audio_mime_type === "text/plain" ? "_link.txt" : "." + this.audio_mime_type.split("/")[1]; | |
this.audio_ext = this.audio_ext === '.x-wav' ? '.wav' : this.audio_ext; | |
this.seek_lock = false; | |
this.seek_dragon = false; | |
this.seek_drag_timer = null; | |
} | |
connect_signals() { | |
super.connect_signals() | |
this.connect(this.model.play_pause_button.properties.active.change, this.play_pause_press); | |
this.connect(this.model.stop_button.properties.active.change, this.stop); | |
this.connect(this.model.download_button.properties.active.change, () => download(this.model.audio_source, this.model.default_title.value + this.audio_ext, this.audio_mime_type)); | |
this.connect(this.model.seek_bar.properties.value.change, this.seek); | |
this.connect(this.model.volume_bar.properties.value.change, () => this.audio.volume(this.model.volume_bar.value)); | |
} | |
play() { | |
if (this.audio.state() === "loaded") { | |
this.audio.play(); | |
this.step(); | |
} else { | |
this.model.play_pause_button.active = false; | |
} | |
} | |
pause() { | |
this.audio.pause(); | |
} | |
stop() { | |
this.audio.stop(); | |
if (this.model.stop_button.active) { | |
this.model.stop_button.active = false; | |
} | |
this.step(); | |
} | |
play_pause_press() { | |
if (this.model.play_pause_button.active) { | |
this.play(); | |
} else { | |
this.pause(); | |
} | |
} | |
finish_drag() { | |
this.model.play_pause_button.active = true; | |
this.seek_dragon = false; // they're dangerous, they breathe fire | |
} | |
seek() { | |
if (this.seek_dragon) { | |
clearTimeout(this.seek_drag_timer); | |
this.seek_drag_timer = setTimeout(this.finish_drag, 500); | |
} | |
if (!this.audio.playing()) { | |
this.audio.seek(this.model.seek_bar.value); | |
} else if (!this.seek_lock) { | |
this.model.play_pause_button.active = false; | |
this.audio.seek(this.model.seek_bar.value); | |
this.seek_drag_timer = setTimeout(this.finish_drag, 50); | |
this.seek_dragon = true; // they're fun to fly on! | |
} | |
} | |
update_seek_bar() { | |
this.seek_lock = true; | |
this.model.seek_bar.value = this.audio.seek(); | |
this.seek_lock = false; | |
} | |
step() { | |
this.update_seek_bar(); | |
if (this.audio.playing()) { | |
requestAnimationFrame(this.step); | |
} | |
} | |
} | |
export var AudioPlayerModel = (function() { | |
AudioPlayerModel = class AudioPlayerModel extends Column { | |
static __name__ = "AudioPlayerModel" | |
static initClass() { | |
this.prototype.default_view = AudioPlayerView; | |
this.define({ | |
audio_source: [p.String, ], | |
default_title: [p.Any, ], | |
play_pause_button: [p.Any, ], | |
stop_button: [p.Any, ], | |
download_button: [p.Any, ], | |
seek_bar: [p.Any, ], | |
volume_bar: [p.Any, ] | |
}); | |
} | |
}; | |
AudioPlayerModel.initClass(); | |
return AudioPlayerModel; | |
})(); | |
""" | |
__implementation__ = JavaScript(JS) | |
audio_source = bkc.properties.String(help="Audio file or base64 encoded file with header.") | |
default_title = bkc.properties.Instance(bkm.widgets.TextInput, help="Audio player default_title, also used as download filename.") | |
play_pause_button = bkc.properties.Instance(bkm.widgets.Toggle, help="Toggle used to control audio playback.") | |
stop_button = bkc.properties.Instance(bkm.widgets.Toggle, help="Button used to halt audio playback.") | |
download_button = bkc.properties.Instance(bkm.widgets.Toggle, help="Button used to download audio file.") | |
seek_bar = bkc.properties.Instance(bkm.widgets.Slider, help="Seek bar to control playback.") | |
volume_bar = bkc.properties.Instance(bkm.widgets.Slider, help="Volume bar to control playback gain.") | |
if __name__ == '__main__': | |
## Usage Example - a moving span representing a seek bar | |
from bokeh.plotting import figure, output_file, show | |
from bokeh.layouts import layout | |
# Params | |
# LIGO binary black hole merger | |
# Data: GW150914 H1 from https://www.gw-openscience.org/audio/ | |
# It's been whitened, bandpassed, frequency shifted +400 Hz for auralization | |
audio_source = 'https://www.gw-openscience.org/GW150914data/GW150914_H1_shifted.wav' # link or base64-encoded wavefile string | |
default_title = 'gravitional_waves_black_hole_merger' # will become wavefile name when downloading base64 | |
title = default_title.replace('_', ' ').title() | |
plot_filename = default_title + '.html' | |
x_axis_label = 'Time (s)' | |
y_axis_label = 'Relative Spacetime Strain' | |
plot_width, plot_height = 800, 500 | |
seek_bar_colour = 'green' # the best colour, well, hmm, might look good in purple too | |
seek_bar_width = 3 | |
seek_bar_alpha = 0.4 | |
time_series_start = 0 # offset in seconds | |
# Player setup - this is from a larger project, please forgive the weird syntax that's taken out of context | |
player_options = {} | |
player_options.setdefault("default_title", bkm.widgets.TextInput(value=default_title, title='', width=300, height=30, sizing_mode='scale_both')) | |
player_options.setdefault("play_pause_button", bkm.widgets.Toggle(label='►', width=50, height=30)) | |
player_options.setdefault("stop_button", bkm.widgets.Toggle(label='■', width=50, height=30)) | |
player_options.setdefault("download_button", bkm.widgets.Toggle(label='⤓', width=50, height=30)) | |
player_options.setdefault("seek_bar", bkm.widgets.Slider(start=0, end=100, step=0.1, value=0, title="Time [s]", width=225, height=30, sizing_mode='scale_both')) | |
player_options.setdefault("volume_bar", bkm.widgets.Slider(start=0, end=1, step=0.01, value=1, title="Gain", width=225, height=30, sizing_mode='scale_both')) | |
player_options.setdefault("sizing_mode", 'scale_both') | |
all_widgets = [player_options["default_title"], player_options["volume_bar"], player_options["seek_bar"], | |
player_options["play_pause_button"], player_options["stop_button"], player_options["download_button"]] | |
player_options.setdefault("children", all_widgets) | |
player_options.setdefault('audio_source', audio_source) | |
# Plot setup | |
try: # to plot actual ligo data | |
from io import BytesIO | |
import requests | |
import numpy as np | |
from scipy.io import wavfile | |
fs, data = wavfile.read(BytesIO(requests.get(audio_source).content)) | |
data = data / 2 ** (data.dtype.itemsize * 8 - 1) # normalize wavefile PCM even though amplitude is already relative | |
time_steps = np.linspace(0, data.size / fs, data.size) # seconds | |
except Exception: | |
import random | |
time_steps = list(range(1000)) | |
data = [random.random() for _ in time_steps] | |
time_series_plot = figure(plot_width=plot_width, plot_height=plot_height, | |
title=title, x_axis_label=x_axis_label, y_axis_label=y_axis_label) | |
time_series_plot.line(time_steps, data) | |
# would be cooler / more useful to look at a spectrogram, but that's off-topic | |
player = AudioPlayerModel(**player_options) | |
seek_bar_span = bkm.Span(dimension="height", line_color=seek_bar_colour, | |
line_width=seek_bar_width, line_alpha=seek_bar_alpha, | |
tags=[time_series_start]) | |
cb_obj = None # the following function is PyScript, NOT Python | |
def set_span_loc(seek_bar_span=seek_bar_span, window=None): | |
seek_bar_span.location = None if cb_obj.value == 0 else cb_obj.value + seek_bar_span.tags[0] | |
# player.seek_bar.js_on_change('value', bkm.CustomJS.from_py_func(set_span_loc)) | |
player.seek_bar.js_link('value', seek_bar_span, 'location') | |
time_series_plot.add_layout(seek_bar_span) | |
grid = layout([[time_series_plot, player]]) | |
output_file(plot_filename) | |
show(grid) |
Ah, thank you @mvernooy3687 for letting me know! Admittedly I haven't used this for awhile. It seems like there were some pretty major behind-the-scenes updates to the BokehJS side in version 3.0. Keeping this here as it will likely be useful for eventual migration: https://discourse.bokeh.org/t/bokeh-3-1-1-breaks-precompiled-custom-models/10761/2
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I have used this code for a long time and it has been very helpful, thanks for this! When trying to run this with bokeh version 2.4.3 it works fine, however with bokeh >= 3 it fails; it produces an html file, but it is completely blank, and in the produced html file gravitional_waves_black_hole_merger_3.0.3.html the error entry point is
exports.AudioPlayerModel = class AudioPlayerModel extends column_1.Column { static __name__ = "AudioPlayerModel"; static initClass() { this.prototype.default_view = AudioPlayerView; this.define({ audio_source: [p.String,], default_title: [p.Any,], play_pause_button: [p.Any,], stop_button: [p.Any,], download_button: [p.Any,], seek_bar: [p.Any,], volume_bar: [p.Any,] }); }
, then in bokeh-3.1.0.min.js:let i; e instanceof a.PropertyAlias ? Object.defineProperty(this.properties, t, { get: ()=>this.properties[e.attr], configurable: !1, enumerable: !1 }) : (i = e instanceof h.Kind ? new a.PrimitiveProperty(this,t,e,s,n) : new e(this,t,h.Any,s,n), this.properties[t] = i)
throws error caught (in promise) TypeError: e is not a constructor.
Any idea what's wrong?
Thanks