Skip to content

Instantly share code, notes, and snippets.

@nathanielatom
Last active January 18, 2024 05:53
Show Gist options
  • Save nathanielatom/6442f94faca69dcf6efae146cb66c30c to your computer and use it in GitHub Desktop.
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).
# 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)
@nathanielatom
Copy link
Author

Wow, thank you! I've been meaning to update it, but been pretty busy. Your typescript fork will be very helpful :)

@mvernooy3687
Copy link

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

@nathanielatom
Copy link
Author

nathanielatom commented Jan 18, 2024

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