-
-
Save nathanielatom/6442f94faca69dcf6efae146cb66c30c to your computer and use it in GitHub Desktop.
# 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) |
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.
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!
Wow, thank you! I've been meaning to update it, but been pretty busy. Your typescript fork will be very helpful :)
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
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
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.