Skip to content

Instantly share code, notes, and snippets.

@nathanielatom
Last active January 18, 2024 05:53
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • 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

I added a fun example using public LIGO data. In my use case, I'm auralizing a vibration waveform stored in a numpy array, which gets converted to a base64 wavefile string.

@nathanielatom
Copy link
Author

This code is mostly used with bokeh v0.12.15 so far, but it should still work with the latest bokeh (v1.3.4 at the time of writing). Need to update / transpile to pure JS code for bokeh 2. Nodejs > v6.11 is required I think for defining custom models.

@david-macleod
Copy link

Thanks for this, very helpful. If you are interested I forked your gist and converted the coffeescript to typescript to work with v1.3.4. It could definitely do with some more work but the output is functional!

@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