-
-
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) |
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
Wow, thank you! I've been meaning to update it, but been pretty busy. Your typescript fork will be very helpful :)