Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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
class AudioPlayerModel(bkm.layouts.WidgetBox):
"""
Audio player using https://howlerjs.com/.
.. todo:: allow seek bar to drag when playing: where slider onclick => pause audio, offclick => play audio
.. 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.9/howler.core.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/downloadjs/1.4.7/download.min.js"]
__implementation__ = """
import * as p from "core/properties"
import {WidgetBox, WidgetBoxView} from "models/layouts/widget_box"
export class AudioPlayerView extends WidgetBoxView
initialize: (options) ->
super(options)
@audio = new Howl({src: [@model.audio_source]})
@audio.on('play', () => @model.play_pause_button.label = "Pause")
@audio.on('pause', () => @model.play_pause_button.label = "Play")
@audio.on('stop', () => @model.play_pause_button.label = "Play"; @model.play_pause_button.active = false)
@audio.on('load', () => @model.seek_bar.end = @audio.duration())
@audio.on('end', () => @stop())
@audio_mime_type = if @model.audio_source.includes(';base64,') then @model.audio_source.split(';base64,')[0].split(':')[1] else "text/plain"
@audio_ext = if @audio_mime_type == "text/plain" then "_link.txt" else "." + @audio_mime_type.split("/")[1]
@audio_ext = if @audio_ext == '.x-wav' then '.wav' else @audio_ext
@connect(@model.play_pause_button.properties.active.change, @play_pause_press)
@connect(@model.stop_button.properties.clicks.change, @stop)
@connect(@model.download_button.properties.clicks.change, () => download(@model.audio_source, @model.default_title.value + @audio_ext, @audio_mime_type))
@connect(@model.seek_bar.properties.value.change, () => @audio.seek(@model.seek_bar.value) if not @audio.playing())
@connect(@model.volume_bar.properties.value.change, () => @audio.volume(@model.volume_bar.value))
play: () =>
if @audio.state() == "loaded"
@audio.play()
@step()
else
@model.play_pause_button.active = false
pause: () =>
@audio.pause()
stop: () =>
@audio.stop()
@step()
play_pause_press: () =>
if @model.play_pause_button.active
@play()
else
@pause()
update_seek_bar: () =>
@model.seek_bar.value = @audio.seek()
step: () =>
@update_seek_bar()
if @audio.playing()
requestAnimationFrame(@step)
export class AudioPlayerModel extends WidgetBox
default_view: AudioPlayerView
type: "AudioPlayerModel"
@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, ]
}
"""
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.Button, help="Button used to halt audio playback.")
download_button = bkc.properties.Instance(bkm.widgets.Button, 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
seek_bar_throttle = 15
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, sizing_mode='scale_width'))
player_options.setdefault("play_pause_button", bkm.widgets.Toggle(label="Play", width=100, button_type="success"))
player_options.setdefault("stop_button", bkm.widgets.Button(label="Stop", width=100, button_type="success"))
player_options.setdefault("download_button", bkm.widgets.Button(label="Save", width=100, button_type="success"))
player_options.setdefault("seek_bar", bkm.widgets.Slider(start=0, end=100, step=0.1, value=0, title="Time [s]", width=250, sizing_mode='scale_width'))
player_options.setdefault("volume_bar", bkm.widgets.Slider(start=0, end=1, step=0.01, value=1, title="Gain", width=250, sizing_mode='scale_width'))
player_options.setdefault("sizing_mode", "scale_width")
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.callback_throttle = seek_bar_throttle
time_series_plot.add_layout(seek_bar_span)
grid = layout([[time_series_plot, player]])
output_file(plot_filename)
show(grid)
@nathanielatom

This comment has been minimized.

Copy link
Owner Author

nathanielatom commented Sep 11, 2019

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

This comment has been minimized.

Copy link
Owner Author

nathanielatom commented Sep 11, 2019

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

This comment has been minimized.

Copy link

david-macleod commented Sep 11, 2019

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

This comment has been minimized.

Copy link
Owner Author

nathanielatom commented Sep 11, 2019

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.