Bug 1057042 - refactor front end of web audio editor. r=vp
☠☠ backed out by 6ebbcb6f3b03 ☠ ☠
authorJordan Santell <jsantell@gmail.com>
Fri, 19 Sep 2014 17:19:00 +0200
changeset 206467 c2e654ecbd5002c196603410a81a31208dcbe08a
parent 206379 f1a36cb2bba68ca4a14b1441129576b3ebb6032a
child 206468 4d9e5795ee825b797c3a9cc71b865b0ea6e90507
push id27528
push userryanvm@gmail.com
push dateMon, 22 Sep 2014 19:27:54 +0000
treeherdermozilla-central@d8688cafc752 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersvp
bugs1057042
milestone35.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1057042 - refactor front end of web audio editor. r=vp
browser/devtools/jar.mn
browser/devtools/webaudioeditor/controller.js
browser/devtools/webaudioeditor/includes.js
browser/devtools/webaudioeditor/models.js
browser/devtools/webaudioeditor/panel.js
browser/devtools/webaudioeditor/test/browser.ini
browser/devtools/webaudioeditor/test/browser_wa_destroy-node-01.js
browser/devtools/webaudioeditor/test/browser_wa_graph-click.js
browser/devtools/webaudioeditor/test/browser_wa_graph-markers.js
browser/devtools/webaudioeditor/test/browser_wa_graph-render-01.js
browser/devtools/webaudioeditor/test/browser_wa_graph-render-02.js
browser/devtools/webaudioeditor/test/browser_wa_graph-render-05.js
browser/devtools/webaudioeditor/test/browser_wa_graph-zoom.js
browser/devtools/webaudioeditor/test/browser_wa_inspector-toggle.js
browser/devtools/webaudioeditor/test/browser_wa_inspector.js
browser/devtools/webaudioeditor/test/browser_wa_properties-view-edit-01.js
browser/devtools/webaudioeditor/test/browser_wa_properties-view-edit-02.js
browser/devtools/webaudioeditor/test/browser_wa_properties-view-media-nodes.js
browser/devtools/webaudioeditor/test/browser_wa_properties-view-params-objects.js
browser/devtools/webaudioeditor/test/browser_wa_properties-view-params.js
browser/devtools/webaudioeditor/test/browser_wa_properties-view.js
browser/devtools/webaudioeditor/test/browser_wa_reset-03.js
browser/devtools/webaudioeditor/test/doc_connect-toggle-param.html
browser/devtools/webaudioeditor/test/head.js
browser/devtools/webaudioeditor/views/context.js
browser/devtools/webaudioeditor/views/inspector.js
browser/devtools/webaudioeditor/views/utils.js
browser/devtools/webaudioeditor/webaudioeditor-controller.js
browser/devtools/webaudioeditor/webaudioeditor-view.js
browser/devtools/webaudioeditor/webaudioeditor.xul
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -68,21 +68,25 @@ browser.jar:
     content/browser/devtools/debugger-controller.js                    (debugger/debugger-controller.js)
     content/browser/devtools/debugger-view.js                          (debugger/debugger-view.js)
     content/browser/devtools/debugger-toolbar.js                       (debugger/debugger-toolbar.js)
     content/browser/devtools/debugger-panes.js                         (debugger/debugger-panes.js)
     content/browser/devtools/shadereditor.xul                          (shadereditor/shadereditor.xul)
     content/browser/devtools/shadereditor.js                           (shadereditor/shadereditor.js)
     content/browser/devtools/canvasdebugger.xul                        (canvasdebugger/canvasdebugger.xul)
     content/browser/devtools/canvasdebugger.js                         (canvasdebugger/canvasdebugger.js)
+    content/browser/devtools/d3.js                                     (shared/d3.js)
     content/browser/devtools/webaudioeditor.xul                        (webaudioeditor/webaudioeditor.xul)
-    content/browser/devtools/d3.js                                     (shared/d3.js)
     content/browser/devtools/dagre-d3.js                               (webaudioeditor/lib/dagre-d3.js)
-    content/browser/devtools/webaudioeditor-controller.js              (webaudioeditor/webaudioeditor-controller.js)
-    content/browser/devtools/webaudioeditor-view.js                    (webaudioeditor/webaudioeditor-view.js)
+    content/browser/devtools/webaudioeditor/includes.js                (webaudioeditor/includes.js)
+    content/browser/devtools/webaudioeditor/models.js                  (webaudioeditor/models.js)
+    content/browser/devtools/webaudioeditor/controller.js              (webaudioeditor/controller.js)
+    content/browser/devtools/webaudioeditor/views/utils.js             (webaudioeditor/views/utils.js)
+    content/browser/devtools/webaudioeditor/views/context.js           (webaudioeditor/views/context.js)
+    content/browser/devtools/webaudioeditor/views/inspector.js         (webaudioeditor/views/inspector.js)
     content/browser/devtools/profiler.xul                              (profiler/profiler.xul)
     content/browser/devtools/profiler.js                               (profiler/profiler.js)
     content/browser/devtools/ui-recordings.js                          (profiler/ui-recordings.js)
     content/browser/devtools/ui-profile.js                             (profiler/ui-profile.js)
     content/browser/devtools/responsivedesign/resize-commands.js       (responsivedesign/resize-commands.js)
     content/browser/devtools/commandline.css                           (commandline/commandline.css)
     content/browser/devtools/commandlineoutput.xhtml                   (commandline/commandlineoutput.xhtml)
     content/browser/devtools/commandlinetooltip.xhtml                  (commandline/commandlinetooltip.xhtml)
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/controller.js
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * A collection of `AudioNodeModel`s used throughout the editor
+ * to keep track of audio nodes within the audio context.
+ */
+let gAudioNodes = new AudioNodesCollection();
+
+/**
+ * Initializes the web audio editor views
+ */
+function startupWebAudioEditor() {
+  return all([
+    WebAudioEditorController.initialize(),
+    ContextView.initialize(),
+    InspectorView.initialize()
+  ]);
+}
+
+/**
+ * Destroys the web audio editor controller and views.
+ */
+function shutdownWebAudioEditor() {
+  return all([
+    WebAudioEditorController.destroy(),
+    ContextView.destroy(),
+    InspectorView.destroy(),
+  ]);
+}
+
+/**
+ * Functions handling target-related lifetime events.
+ */
+let WebAudioEditorController = {
+  /**
+   * Listen for events emitted by the current tab target.
+   */
+  initialize: function() {
+    telemetry.toolOpened("webaudioeditor");
+    this._onTabNavigated = this._onTabNavigated.bind(this);
+    this._onThemeChange = this._onThemeChange.bind(this);
+
+    gTarget.on("will-navigate", this._onTabNavigated);
+    gTarget.on("navigate", this._onTabNavigated);
+    gFront.on("start-context", this._onStartContext);
+    gFront.on("create-node", this._onCreateNode);
+    gFront.on("connect-node", this._onConnectNode);
+    gFront.on("connect-param", this._onConnectParam);
+    gFront.on("disconnect-node", this._onDisconnectNode);
+    gFront.on("change-param", this._onChangeParam);
+    gFront.on("destroy-node", this._onDestroyNode);
+
+    // Hook into theme change so we can change
+    // the graph's marker styling, since we can't do this
+    // with CSS
+    gDevTools.on("pref-changed", this._onThemeChange);
+  },
+
+  /**
+   * Remove events emitted by the current tab target.
+   */
+  destroy: function() {
+    telemetry.toolClosed("webaudioeditor");
+    gTarget.off("will-navigate", this._onTabNavigated);
+    gTarget.off("navigate", this._onTabNavigated);
+    gFront.off("start-context", this._onStartContext);
+    gFront.off("create-node", this._onCreateNode);
+    gFront.off("connect-node", this._onConnectNode);
+    gFront.off("connect-param", this._onConnectParam);
+    gFront.off("disconnect-node", this._onDisconnectNode);
+    gFront.off("change-param", this._onChangeParam);
+    gFront.off("destroy-node", this._onDestroyNode);
+    gDevTools.off("pref-changed", this._onThemeChange);
+  },
+
+  /**
+   * Called when page is reloaded to show the reload notice and waiting
+   * for an audio context notice.
+   */
+  reset: function () {
+    $("#content").hidden = true;
+    ContextView.resetUI();
+    InspectorView.resetUI();
+  },
+
+  // Since node create and connect are probably executed back to back,
+  // and the controller's `_onCreateNode` needs to look up type,
+  // the edge creation could be called before the graph node is actually
+  // created. This way, we can check and listen for the event before
+  // adding an edge.
+  _waitForNodeCreation: function (sourceActor, destActor) {
+    let deferred = defer();
+    let source = gAudioNodes.get(sourceActor.actorID);
+    let dest = gAudioNodes.get(destActor.actorID);
+
+    if (!source || !dest) {
+      gAudioNodes.on("add", function createNodeListener (createdNode) {
+        if (sourceActor.actorID === createdNode.id)
+          source = createdNode;
+        if (destActor.actorID === createdNode.id)
+          dest = createdNode;
+        if (source && dest) {
+          gAudioNodes.off("add", createNodeListener);
+          deferred.resolve([source, dest]);
+        }
+      });
+    }
+    else {
+      deferred.resolve([source, dest]);
+    }
+    return deferred.promise;
+  },
+
+  /**
+   * Fired when the devtools theme changes (light, dark, etc.)
+   * so that the graph can update marker styling, as that
+   * cannot currently be done with CSS.
+   */
+  _onThemeChange: function (event, data) {
+    window.emit(EVENTS.THEME_CHANGE, data.newValue);
+  },
+
+  /**
+   * Called for each location change in the debugged tab.
+   */
+  _onTabNavigated: Task.async(function* (event, {isFrameSwitching}) {
+    switch (event) {
+      case "will-navigate": {
+        // Make sure the backend is prepared to handle audio contexts.
+        if (!isFrameSwitching) {
+          yield gFront.setup({ reload: false });
+        }
+
+        // Clear out current UI.
+        this.reset();
+
+        // When switching to an iframe, ensure displaying the reload button.
+        // As the document has already been loaded without being hooked.
+        if (isFrameSwitching) {
+          $("#reload-notice").hidden = false;
+          $("#waiting-notice").hidden = true;
+        } else {
+          // Otherwise, we are loading a new top level document,
+          // so we don't need to reload anymore and should receive
+          // new node events.
+          $("#reload-notice").hidden = true;
+          $("#waiting-notice").hidden = false;
+        }
+
+        // Clear out stored audio nodes
+        gAudioNodes.reset();
+
+        window.emit(EVENTS.UI_RESET);
+        break;
+      }
+      case "navigate": {
+        // TODO Case of bfcache, needs investigating
+        // bug 994250
+        break;
+      }
+    }
+  }),
+
+  /**
+   * Called after the first audio node is created in an audio context,
+   * signaling that the audio context is being used.
+   */
+  _onStartContext: function() {
+    $("#reload-notice").hidden = true;
+    $("#waiting-notice").hidden = true;
+    $("#content").hidden = false;
+    window.emit(EVENTS.START_CONTEXT);
+  },
+
+  /**
+   * Called when a new node is created. Creates an `AudioNodeView` instance
+   * for tracking throughout the editor.
+   */
+  _onCreateNode: Task.async(function* (nodeActor) {
+    yield gAudioNodes.add(nodeActor);
+  }),
+
+  /**
+   * Called on `destroy-node` when an AudioNode is GC'd. Removes
+   * from the AudioNode array and fires an event indicating the removal.
+   */
+  _onDestroyNode: function (nodeActor) {
+    gAudioNodes.remove(gAudioNodes.get(nodeActor.actorID));
+  },
+
+  /**
+   * Called when a node is connected to another node.
+   */
+  _onConnectNode: Task.async(function* ({ source: sourceActor, dest: destActor }) {
+    let [source, dest] = yield WebAudioEditorController._waitForNodeCreation(sourceActor, destActor);
+    source.connect(dest);
+  }),
+
+  /**
+   * Called when a node is conneceted to another node's AudioParam.
+   */
+  _onConnectParam: Task.async(function* ({ source: sourceActor, dest: destActor, param }) {
+    let [source, dest] = yield WebAudioEditorController._waitForNodeCreation(sourceActor, destActor);
+    source.connect(dest, param);
+  }),
+
+  /**
+   * Called when a node is disconnected.
+   */
+  _onDisconnectNode: function(nodeActor) {
+    let node = gAudioNodes.get(nodeActor.actorID);
+    node.disconnect();
+  },
+
+  /**
+   * Called when a node param is changed.
+   */
+  _onChangeParam: function({ actor, param, value }) {
+    window.emit(EVENTS.CHANGE_PARAM, gAudioNodes.get(actor.actorID), param, value);
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/includes.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource:///modules/devtools/gDevTools.jsm");
+
+const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
+
+let { console } = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
+let { EventTarget } = require("sdk/event/target");
+const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
+const { Class } = require("sdk/core/heritage");
+const EventEmitter = require("devtools/toolkit/event-emitter");
+const STRINGS_URI = "chrome://browser/locale/devtools/webaudioeditor.properties"
+const L10N = new ViewHelpers.L10N(STRINGS_URI);
+const Telemetry = require("devtools/shared/telemetry");
+const telemetry = new Telemetry();
+
+// Override DOM promises with Promise.jsm helpers
+const { defer, all } = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+
+/* Events fired on `window` to indicate state or actions*/
+const EVENTS = {
+  // Fired when the first AudioNode has been created, signifying
+  // that the AudioContext is being used and should be tracked via the editor.
+  START_CONTEXT: "WebAudioEditor:StartContext",
+
+  // When the devtools theme changes.
+  THEME_CHANGE: "WebAudioEditor:ThemeChange",
+
+  // When the UI is reset from tab navigation.
+  UI_RESET: "WebAudioEditor:UIReset",
+
+  // When a param has been changed via the UI and successfully
+  // pushed via the actor to the raw audio node.
+  UI_SET_PARAM: "WebAudioEditor:UISetParam",
+
+  // When a node is to be set in the InspectorView.
+  UI_SELECT_NODE: "WebAudioEditor:UISelectNode",
+
+  // When the inspector is finished setting a new node.
+  UI_INSPECTOR_NODE_SET: "WebAudioEditor:UIInspectorNodeSet",
+
+  // When the inspector is finished rendering in or out of view.
+  UI_INSPECTOR_TOGGLED: "WebAudioEditor:UIInspectorToggled",
+
+  // When an audio node is finished loading in the Properties tab.
+  UI_PROPERTIES_TAB_RENDERED: "WebAudioEditor:UIPropertiesTabRendered",
+
+  // When the Audio Context graph finishes rendering.
+  // Is called with two arguments, first representing number of nodes
+  // rendered, second being the number of edge connections rendering (not counting
+  // param edges), followed by the count of the param edges rendered.
+  UI_GRAPH_RENDERED: "WebAudioEditor:UIGraphRendered"
+};
+
+/**
+ * The current target and the Web Audio Editor front, set by this tool's host.
+ */
+let gToolbox, gTarget, gFront;
+
+/**
+ * Convenient way of emitting events from the panel window.
+ */
+EventEmitter.decorate(this);
+
+/**
+ * DOM query helper.
+ */
+function $(selector, target = document) { return target.querySelector(selector); }
+function $$(selector, target = document) { return target.querySelectorAll(selector); }
+
+/**
+ * Takes an iterable collection, and a hash. Return the first
+ * object in the collection that matches the values in the hash.
+ * From Backbone.Collection#findWhere
+ * http://backbonejs.org/#Collection-findWhere
+ */
+function findWhere (collection, attrs) {
+  let keys = Object.keys(attrs);
+  for (let model of collection) {
+    if (keys.every(key => model[key] === attrs[key])) {
+      return model;
+    }
+  }
+  return void 0;
+}
+
+function mixin (source, ...args) {
+  args.forEach(obj => Object.keys(obj).forEach(prop => source[prop] = obj[prop]));
+  return source;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/models.js
@@ -0,0 +1,274 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+// Import as different name `coreEmit`, so we don't conflict
+// with the global `window` listener itself.
+const { emit: coreEmit } = require("sdk/event/core");
+
+/**
+ * Representational wrapper around AudioNodeActors. Adding and destroying
+ * AudioNodes should be performed through the AudioNodes collection.
+ *
+ * Events:
+ * - `connect`: node, destinationNode, parameter
+ * - `disconnect`: node
+ */
+const AudioNodeModel = Class({
+  extends: EventTarget,
+
+  // Will be added via AudioNodes `add`
+  collection: null,
+
+  initialize: function (actor) {
+    this.actor = actor;
+    this.id = actor.actorID;
+    this.connections = [];
+  },
+
+  /**
+   * After instantiating the AudioNodeModel, calling `setup` caches values
+   * from the actor onto the model. In this case, only the type of audio node.
+   *
+   * @return promise
+   */
+  setup: Task.async(function* () {
+    yield this.getType();
+  }),
+
+  /**
+   * A proxy for the underlying AudioNodeActor to fetch its type
+   * and subsequently assign the type to the instance.
+   *
+   * @return Promise->String
+   */
+  getType: Task.async(function* () {
+    this.type = yield this.actor.getType();
+    return this.type;
+  }),
+
+  /**
+   * Stores connection data inside this instance of this audio node connecting
+   * to another node (destination). If connecting to another node's AudioParam,
+   * the second argument (param) must be populated with a string.
+   *
+   * Connecting nodes is idempotent. Upon new connection, emits "connect" event.
+   *
+   * @param AudioNodeModel destination
+   * @param String param
+   */
+  connect: function (destination, param) {
+    let edge = findWhere(this.connections, { destination: destination.id, param: param });
+
+    if (!edge) {
+      this.connections.push({ source: this.id, destination: destination.id, param: param });
+      coreEmit(this, "connect", this, destination, param);
+    }
+  },
+
+  /**
+   * Clears out all internal connection data. Emits "disconnect" event.
+   */
+  disconnect: function () {
+    this.connections.length = 0;
+    coreEmit(this, "disconnect", this);
+  },
+
+  /**
+   * Returns a promise that resolves to an array of objects containing
+   * both a `param` name property and a `value` property.
+   *
+   * @return Promise->Object
+   */
+  getParams: function () {
+    return this.actor.getParams();
+  },
+
+  /**
+   * Takes a `dagreD3.Digraph` object and adds this node to
+   * the graph to be rendered.
+   *
+   * @param dagreD3.Digraph
+   */
+  addToGraph: function (graph) {
+    graph.addNode(this.id, {
+      type: this.type,
+      label: this.type.replace(/Node$/, ""),
+      id: this.id
+    });
+  },
+
+  /**
+   * Takes a `dagreD3.Digraph` object and adds edges to
+   * the graph to be rendered. Separate from `addToGraph`,
+   * as while we depend on D3/Dagre's constraints, we cannot
+   * add edges for nodes that have not yet been added to the graph.
+   *
+   * @param dagreD3.Digraph
+   */
+  addEdgesToGraph: function (graph) {
+    for (let edge of this.connections) {
+      let options = {
+        source: this.id,
+        target: edge.destination
+      };
+
+      // Only add `label` if `param` specified, as this is an AudioParam
+      // connection then. `label` adds the magic to render with dagre-d3,
+      // and `param` is just more explicitly the param, ignoring
+      // implementation details.
+      if (edge.param) {
+        options.label = options.param = edge.param;
+      }
+
+      graph.addEdge(null, this.id, edge.destination, options);
+    }
+  }
+});
+
+
+/**
+ * Constructor for a Collection of `AudioNodeModel` models.
+ *
+ * Events:
+ * - `add`: node
+ * - `remove`: node
+ * - `connect`: node, destinationNode, parameter
+ * - `disconnect`: node
+ */
+const AudioNodesCollection = Class({
+  extends: EventTarget,
+
+  model: AudioNodeModel,
+
+  initialize: function () {
+    this.models = new Set();
+    this._onModelEvent = this._onModelEvent.bind(this);
+  },
+
+  /**
+   * Iterates over all models within the collection, calling `fn` with the
+   * model as the first argument.
+   *
+   * @param Function fn
+   */
+  forEach: function (fn) {
+    this.models.forEach(fn);
+  },
+
+  /**
+   * Creates a new AudioNodeModel, passing through arguments into the AudioNodeModel
+   * constructor, and adds the model to the internal collection store of this
+   * instance.
+   *
+   * Also calls `setup` on the model itself, and sets up event piping, so that
+   * events emitted on each model propagate to the collection itself.
+   *
+   * Emits "add" event on instance when completed.
+   *
+   * @param Object obj
+   * @return Promise->AudioNodeModel
+   */
+  add: Task.async(function* (obj) {
+    let node = new this.model(obj);
+    node.collection = this;
+    yield node.setup();
+
+    this.models.add(node);
+
+    node.on("*", this._onModelEvent);
+    coreEmit(this, "add", node);
+    return node;
+  }),
+
+  /**
+   * Removes an AudioNodeModel from the internal collection. Calls `delete` method
+   * on the model, and emits "remove" on this instance.
+   *
+   * @param AudioNodeModel node
+   */
+  remove: function (node) {
+    this.models.delete(node);
+    coreEmit(this, "remove", node);
+  },
+
+  /**
+   * Empties out the internal collection of all AudioNodeModels.
+   */
+  reset: function () {
+    this.models.clear();
+  },
+
+  /**
+   * Takes an `id` from an AudioNodeModel and returns the corresponding
+   * AudioNodeModel within the collection that matches that id. Returns `null`
+   * if not found.
+   *
+   * @param Number id
+   * @return AudioNodeModel|null
+   */
+  get: function (id) {
+    return findWhere(this.models, { id: id });
+  },
+
+  /**
+   * Returns the count for how many models are a part of this collection.
+   *
+   * @return Number
+   */
+  get length() {
+    return this.models.size;
+  },
+
+  /**
+   * Returns detailed information about the collection. used during tests
+   * to query state. Returns an object with information on node count,
+   * how many edges are within the data graph, as well as how many of those edges
+   * are for AudioParams.
+   *
+   * @return Object
+   */
+  getInfo: function () {
+    let info = {
+      nodes: this.length,
+      edges: 0,
+      paramEdges: 0
+    };
+
+    this.models.forEach(node => {
+      let paramEdgeCount = node.connections.filter(edge => edge.param).length;
+      info.edges += node.connections.length - paramEdgeCount;
+      info.paramEdges += paramEdgeCount;
+    });
+    return info;
+  },
+
+  /**
+   * Adds all nodes within the collection to the passed in graph,
+   * as well as their corresponding edges.
+   *
+   * @param dagreD3.Digraph
+   */
+  populateGraph: function (graph) {
+    this.models.forEach(node => node.addToGraph(graph));
+    this.models.forEach(node => node.addEdgesToGraph(graph));
+  },
+
+  /**
+   * Called when a stored model emits any event. Used to manage
+   * event propagation, or listening to model events to react, like
+   * removing a model from the collection when it's destroyed.
+   */
+  _onModelEvent: function (eventName, node, ...args) {
+    if (eventName === "remove") {
+      // If a `remove` event from the model, remove it
+      // from the collection, and let the method handle the emitting on
+      // the collection
+      this.remove(node);
+    } else {
+      // Pipe the event to the collection
+      coreEmit(this, eventName, [node].concat(args));
+    }
+  }
+});
--- a/browser/devtools/webaudioeditor/panel.js
+++ b/browser/devtools/webaudioeditor/panel.js
@@ -30,16 +30,17 @@ WebAudioEditorPanel.prototype = {
     } else {
       targetPromise = Promise.resolve(this.target);
     }
 
     return targetPromise
       .then(() => {
         this.panelWin.gToolbox = this._toolbox;
         this.panelWin.gTarget = this.target;
+
         this.panelWin.gFront = new WebAudioFront(this.target.client, this.target.form);
         return this.panelWin.startupWebAudioEditor();
       })
       .then(() => {
         this.isReady = true;
         this.emit("ready");
         return this;
       })
--- a/browser/devtools/webaudioeditor/test/browser.ini
+++ b/browser/devtools/webaudioeditor/test/browser.ini
@@ -3,16 +3,17 @@ subsuite = devtools
 support-files =
   doc_simple-context.html
   doc_complex-context.html
   doc_simple-node-creation.html
   doc_buffer-and-array.html
   doc_media-node-creation.html
   doc_destroy-nodes.html
   doc_connect-toggle.html
+  doc_connect-toggle-param.html
   doc_connect-param.html
   doc_connect-multi-param.html
   doc_iframe-context.html
   440hz_sine.ogg
   head.js
 
 [browser_audionode-actor-get-param-flags.js]
 [browser_audionode-actor-get-params-01.js]
@@ -33,16 +34,17 @@ support-files =
 [browser_wa_reset-04.js]
 
 [browser_wa_graph-click.js]
 [browser_wa_graph-markers.js]
 [browser_wa_graph-render-01.js]
 [browser_wa_graph-render-02.js]
 [browser_wa_graph-render-03.js]
 [browser_wa_graph-render-04.js]
+[browser_wa_graph-render-05.js]
 [browser_wa_graph-selected.js]
 [browser_wa_graph-zoom.js]
 
 [browser_wa_inspector.js]
 [browser_wa_inspector-toggle.js]
 
 [browser_wa_properties-view.js]
 [browser_wa_properties-view-edit-01.js]
--- a/browser/devtools/webaudioeditor/test/browser_wa_destroy-node-01.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_destroy-node-01.js
@@ -7,45 +7,45 @@
  * that selecting a soon-to-be dead node clears the inspector.
  *
  * All done in one test since this test takes a few seconds to clear GC.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(DESTROY_NODES_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS } = panelWin;
+  let { gFront, $, $$, gAudioNodes } = panelWin;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
-  let destroyed = getN(panelWin, EVENTS.DESTROY_NODE, 10);
+  let destroyed = getN(gAudioNodes, "remove", 10);
 
   forceCC();
 
   let [created] = yield Promise.all([
-    getNSpread(panelWin, EVENTS.CREATE_NODE, 13),
+    getNSpread(gAudioNodes, "add", 13),
     waitForGraphRendered(panelWin, 13, 2)
   ]);
 
-  // Since CREATE_NODE emits several arguments (eventName and actorID), let's
-  // flatten it to just the actorIDs
-  let actorIDs = created.map(ev => ev[1]);
+  // Flatten arrays of event arguments and take the first (AudioNodeModel)
+  // and get its ID.
+  let actorIDs = created.map(ev => ev[0].id);
 
   // Click a soon-to-be dead buffer node
   yield clickGraphNode(panelWin, actorIDs[5]);
 
   forceCC();
 
   // Wait for destruction and graph to re-render
   yield Promise.all([destroyed, waitForGraphRendered(panelWin, 3, 2)]);
 
   // Test internal storage
-  is(panelWin.AudioNodes.length, 3, "All nodes should be GC'd except one gain, osc and dest node.");
+  is(panelWin.gAudioNodes.length, 3, "All nodes should be GC'd except one gain, osc and dest node.");
 
   // Test graph rendering
   ok(findGraphNode(panelWin, actorIDs[0]), "dest should be in graph");
   ok(findGraphNode(panelWin, actorIDs[1]), "osc should be in graph");
   ok(findGraphNode(panelWin, actorIDs[2]), "gain should be in graph");
 
   let { nodes, edges } = countGraphObjects(panelWin);
 
--- a/browser/devtools/webaudioeditor/test/browser_wa_graph-click.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_graph-click.js
@@ -1,54 +1,51 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Tests that the clicking on a node in the GraphView opens and sets
  * the correct node in the InspectorView
  */
 
-let EVENTS = null;
-
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(COMPLEX_CONTEXT_URL);
   let panelWin = panel.panelWin;
-  let { gFront, $, $$, WebAudioInspectorView } = panelWin;
-  EVENTS = panelWin.EVENTS;
+  let { gFront, $, $$, InspectorView } = panelWin;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors, _] = yield Promise.all([
     getN(gFront, "create-node", 8),
     waitForGraphRendered(panel.panelWin, 8, 8)
   ]);
 
   let nodeIds = actors.map(actor => actor.actorID);
 
-  ok(!WebAudioInspectorView.isVisible(), "InspectorView hidden on start.");
+  ok(!InspectorView.isVisible(), "InspectorView hidden on start.");
 
   yield clickGraphNode(panelWin, nodeIds[1], true);
 
-  ok(WebAudioInspectorView.isVisible(), "InspectorView visible after selecting a node.");
-  is(WebAudioInspectorView.getCurrentAudioNode().id, nodeIds[1], "InspectorView has correct node set.");
+  ok(InspectorView.isVisible(), "InspectorView visible after selecting a node.");
+  is(InspectorView.getCurrentAudioNode().id, nodeIds[1], "InspectorView has correct node set.");
 
   yield clickGraphNode(panelWin, nodeIds[2]);
 
-  ok(WebAudioInspectorView.isVisible(), "InspectorView still visible after selecting another node.");
-  is(WebAudioInspectorView.getCurrentAudioNode().id, nodeIds[2], "InspectorView has correct node set on second node.");
+  ok(InspectorView.isVisible(), "InspectorView still visible after selecting another node.");
+  is(InspectorView.getCurrentAudioNode().id, nodeIds[2], "InspectorView has correct node set on second node.");
 
   yield clickGraphNode(panelWin, nodeIds[2]);
-  is(WebAudioInspectorView.getCurrentAudioNode().id, nodeIds[2], "Clicking the same node again works (idempotent).");
+  is(InspectorView.getCurrentAudioNode().id, nodeIds[2], "Clicking the same node again works (idempotent).");
 
   yield clickGraphNode(panelWin, $("rect", findGraphNode(panelWin, nodeIds[3])));
-  is(WebAudioInspectorView.getCurrentAudioNode().id, nodeIds[3], "Clicking on a <rect> works as expected.");
+  is(InspectorView.getCurrentAudioNode().id, nodeIds[3], "Clicking on a <rect> works as expected.");
 
   yield clickGraphNode(panelWin, $("tspan", findGraphNode(panelWin, nodeIds[4])));
-  is(WebAudioInspectorView.getCurrentAudioNode().id, nodeIds[4], "Clicking on a <tspan> works as expected.");
+  is(InspectorView.getCurrentAudioNode().id, nodeIds[4], "Clicking on a <tspan> works as expected.");
 
-  ok(WebAudioInspectorView.isVisible(),
+  ok(InspectorView.isVisible(),
     "InspectorView still visible after several nodes have been clicked.");
 
   yield teardown(panel);
   finish();
 }
--- a/browser/devtools/webaudioeditor/test/browser_wa_graph-markers.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_graph-markers.js
@@ -3,17 +3,17 @@
 
 /**
  * Tests that the SVG marker styling is updated when devtools theme changes.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, MARKER_STYLING } = panelWin;
+  let { gFront, $, $$, MARKER_STYLING } = panelWin;
 
   let currentTheme = Services.prefs.getCharPref("devtools.theme");
 
   ok(MARKER_STYLING.light, "Marker styling exists for light theme.");
   ok(MARKER_STYLING.dark, "Marker styling exists for dark theme.");
 
   let started = once(gFront, "start-context");
 
--- a/browser/devtools/webaudioeditor/test/browser_wa_graph-render-01.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_graph-render-01.js
@@ -5,23 +5,23 @@
  * Tests that SVG nodes and edges were created for the Graph View.
  */
 
 let connectCount = 0;
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS } = panelWin;
+  let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
-  panelWin.on(EVENTS.CONNECT_NODE, onConnectNode);
+  gAudioNodes.on("connect", onConnectNode);
 
   let [actors] = yield Promise.all([
     get3(gFront, "create-node"),
     waitForGraphRendered(panelWin, 3, 2)
   ]);
 
   let [destId, oscId, gainId] = actors.map(actor => actor.actorID);
 
@@ -30,17 +30,17 @@ function spawnTest() {
   ok(findGraphNode(panelWin, destId).classList.contains("type-AudioDestinationNode"), "found AudioDestinationNode with class");
   is(findGraphEdge(panelWin, oscId, gainId).toString(), "[object SVGGElement]", "found edge for osc -> gain");
   is(findGraphEdge(panelWin, gainId, destId).toString(), "[object SVGGElement]", "found edge for gain -> dest");
 
   yield wait(1000);
 
   is(connectCount, 2, "Only two node connect events should be fired.");
 
-  panelWin.off(EVENTS.CONNECT_NODE, onConnectNode);
+  gAudioNodes.off("connect", onConnectNode);
 
   yield teardown(panel);
   finish();
 }
 
 function onConnectNode () {
   ++connectCount;
 }
--- a/browser/devtools/webaudioeditor/test/browser_wa_graph-render-02.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_graph-render-02.js
@@ -3,17 +3,17 @@
 
 /**
  * Tests more edge rendering for complex graphs.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(COMPLEX_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS } = panelWin;
+  let { gFront, $, $$ } = panelWin;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     getN(gFront, "create-node", 8),
     waitForGraphRendered(panelWin, 8, 8)
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/test/browser_wa_graph-render-05.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests to ensure that param connections trigger graph redraws
+ */
+
+function spawnTest() {
+  let [target, debuggee, panel] = yield initWebAudioEditor(CONNECT_TOGGLE_PARAM_URL);
+  let { panelWin } = panel;
+  let { gFront, $, $$, EVENTS } = panelWin;
+
+  reload(target);
+
+  let [actors] = yield Promise.all([
+    getN(gFront, "create-node", 3),
+    waitForGraphRendered(panelWin, 3, 1, 0)
+  ]);
+  ok(true, "Graph rendered without param connection");
+
+  yield waitForGraphRendered(panelWin, 3, 1, 1);
+  ok(true, "Graph re-rendered upon param connection");
+
+  yield teardown(panel);
+  finish();
+}
+
--- a/browser/devtools/webaudioeditor/test/browser_wa_graph-zoom.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_graph-zoom.js
@@ -3,43 +3,43 @@
 
 /**
  * Tests that the graph's scale and position is reset on a page reload.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioGraphView } = panelWin;
+  let { gFront, $, $$, EVENTS, ContextView } = panelWin;
 
   let started = once(gFront, "start-context");
 
   yield Promise.all([
     reload(target),
     waitForGraphRendered(panelWin, 3, 2)
   ]);
 
-  is(WebAudioGraphView.getCurrentScale(), 1, "Default graph scale is 1.");
-  is(WebAudioGraphView.getCurrentTranslation()[0], 20, "Default x-translation is 20.");
-  is(WebAudioGraphView.getCurrentTranslation()[1], 20, "Default y-translation is 20.");
+  is(ContextView.getCurrentScale(), 1, "Default graph scale is 1.");
+  is(ContextView.getCurrentTranslation()[0], 20, "Default x-translation is 20.");
+  is(ContextView.getCurrentTranslation()[1], 20, "Default y-translation is 20.");
 
   // Change both attribute and D3's internal store
   panelWin.d3.select("#graph-target").attr("transform", "translate([100, 400]) scale(10)");
-  WebAudioGraphView._zoomBinding.scale(10);
-  WebAudioGraphView._zoomBinding.translate([100, 400]);
+  ContextView._zoomBinding.scale(10);
+  ContextView._zoomBinding.translate([100, 400]);
 
-  is(WebAudioGraphView.getCurrentScale(), 10, "After zoom, scale is 10.");
-  is(WebAudioGraphView.getCurrentTranslation()[0], 100, "After zoom, x-translation is 100.");
-  is(WebAudioGraphView.getCurrentTranslation()[1], 400, "After zoom, y-translation is 400.");
+  is(ContextView.getCurrentScale(), 10, "After zoom, scale is 10.");
+  is(ContextView.getCurrentTranslation()[0], 100, "After zoom, x-translation is 100.");
+  is(ContextView.getCurrentTranslation()[1], 400, "After zoom, y-translation is 400.");
 
   yield Promise.all([
     reload(target),
     waitForGraphRendered(panelWin, 3, 2)
   ]);
 
-  is(WebAudioGraphView.getCurrentScale(), 1, "After refresh, graph scale is 1.");
-  is(WebAudioGraphView.getCurrentTranslation()[0], 20, "After refresh, x-translation is 20.");
-  is(WebAudioGraphView.getCurrentTranslation()[1], 20, "After refresh, y-translation is 20.");
+  is(ContextView.getCurrentScale(), 1, "After refresh, graph scale is 1.");
+  is(ContextView.getCurrentTranslation()[0], 20, "After refresh, x-translation is 20.");
+  is(ContextView.getCurrentTranslation()[1], 20, "After refresh, y-translation is 20.");
 
   yield teardown(panel);
   finish();
 }
 
--- a/browser/devtools/webaudioeditor/test/browser_wa_inspector-toggle.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_inspector-toggle.js
@@ -4,55 +4,55 @@
 /**
  * Tests that the inspector toggle button shows and hides
  * the inspector panel as intended.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     get3(gFront, "create-node"),
     waitForGraphRendered(panelWin, 3, 2)
   ]);
   let nodeIds = actors.map(actor => actor.actorID);
 
-  ok(!WebAudioInspectorView.isVisible(), "InspectorView hidden on start.");
+  ok(!InspectorView.isVisible(), "InspectorView hidden on start.");
 
   // Open inspector pane
   $("#inspector-pane-toggle").click();
   yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED);
 
-  ok(WebAudioInspectorView.isVisible(), "InspectorView shown after toggling.");
+  ok(InspectorView.isVisible(), "InspectorView shown after toggling.");
 
   ok(isVisible($("#web-audio-editor-details-pane-empty")),
     "InspectorView empty message should still be visible.");
   ok(!isVisible($("#web-audio-editor-tabs")),
     "InspectorView tabs view should still be hidden.");
   is($("#web-audio-inspector-title").value, "AudioNode Inspector",
     "Inspector should still have default title.");
 
   // Close inspector pane
   $("#inspector-pane-toggle").click();
   yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED);
 
-  ok(!WebAudioInspectorView.isVisible(), "InspectorView back to being hidden.");
+  ok(!InspectorView.isVisible(), "InspectorView back to being hidden.");
 
   // Open again to test node loading while open
   $("#inspector-pane-toggle").click();
   yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED);
 
-  ok(WebAudioInspectorView.isVisible(), "InspectorView being shown.");
+  ok(InspectorView.isVisible(), "InspectorView being shown.");
   ok(!isVisible($("#web-audio-editor-tabs")),
     "InspectorView tabs are still hidden.");
 
   click(panelWin, findGraphNode(panelWin, nodeIds[1]));
   yield once(panelWin, EVENTS.UI_INSPECTOR_NODE_SET);
 
   ok(!isVisible($("#web-audio-editor-details-pane-empty")),
     "Empty message hides even when loading node while open.");
--- a/browser/devtools/webaudioeditor/test/browser_wa_inspector.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_inspector.js
@@ -4,45 +4,45 @@
 /**
  * Tests that inspector view opens on graph node click, and
  * loads the correct node inside the inspector.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     get3(gFront, "create-node"),
     waitForGraphRendered(panelWin, 3, 2)
   ]);
   let nodeIds = actors.map(actor => actor.actorID);
 
-  ok(!WebAudioInspectorView.isVisible(), "InspectorView hidden on start.");
+  ok(!InspectorView.isVisible(), "InspectorView hidden on start.");
   ok(isVisible($("#web-audio-editor-details-pane-empty")),
     "InspectorView empty message should show when no node's selected.");
   ok(!isVisible($("#web-audio-editor-tabs")),
     "InspectorView tabs view should be hidden when no node's selected.");
   is($("#web-audio-inspector-title").value, "AudioNode Inspector",
     "Inspector should have default title when empty.");
 
   click(panelWin, findGraphNode(panelWin, nodeIds[1]));
   // Wait for the node to be set as well as the inspector to come fully into the view
   yield Promise.all([
     once(panelWin, EVENTS.UI_INSPECTOR_NODE_SET),
     once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED)
   ]);
 
-  ok(WebAudioInspectorView.isVisible(), "InspectorView shown once node selected.");
+  ok(InspectorView.isVisible(), "InspectorView shown once node selected.");
   ok(!isVisible($("#web-audio-editor-details-pane-empty")),
     "InspectorView empty message hidden when node selected.");
   ok(isVisible($("#web-audio-editor-tabs")),
     "InspectorView tabs view visible when node selected.");
 
   is($("#web-audio-inspector-title").value, "Oscillator",
     "Inspector should have the node title when a node is selected.");
 
--- a/browser/devtools/webaudioeditor/test/browser_wa_properties-view-edit-01.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_properties-view-edit-01.js
@@ -3,18 +3,18 @@
 
 /**
  * Tests that properties are updated when modifying the VariablesView.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     get3(gFront, "create-node"),
     waitForGraphRendered(panelWin, 3, 2)
--- a/browser/devtools/webaudioeditor/test/browser_wa_properties-view-edit-02.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_properties-view-edit-02.js
@@ -3,18 +3,18 @@
 
 /**
  * Tests that properties are not updated when modifying the VariablesView.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(COMPLEX_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     getN(gFront, "create-node", 8),
     waitForGraphRendered(panelWin, 8, 8)
--- a/browser/devtools/webaudioeditor/test/browser_wa_properties-view-media-nodes.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_properties-view-media-nodes.js
@@ -30,18 +30,18 @@ function waitForDeviceClosed() {
   }, TOPIC, false);
 
   return deferred.promise;
 }
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(MEDIA_NODES_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   // Auto enable getUserMedia
   let mediaPermissionPref = Services.prefs.getBoolPref(MEDIA_PERMISSION);
   Services.prefs.setBoolPref(MEDIA_PERMISSION, true);
 
   reload(target);
 
   let [actors] = yield Promise.all([
--- a/browser/devtools/webaudioeditor/test/browser_wa_properties-view-params-objects.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_properties-view-params-objects.js
@@ -4,18 +4,18 @@
 /**
  * Tests that params view correctly displays non-primitive properties
  * like AudioBuffer and Float32Array in properties of AudioNodes.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(BUFFER_AND_ARRAY_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     getN(gFront, "create-node", 3),
     waitForGraphRendered(panelWin, 3, 2)
--- a/browser/devtools/webaudioeditor/test/browser_wa_properties-view-params.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_properties-view-params.js
@@ -4,18 +4,18 @@
 /**
  * Tests that params view correctly displays all properties for nodes
  * correctly, with default values and correct types.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_NODES_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     getN(gFront, "create-node", 14),
     waitForGraphRendered(panelWin, 14, 0)
--- a/browser/devtools/webaudioeditor/test/browser_wa_properties-view.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_properties-view.js
@@ -3,18 +3,18 @@
 
 /**
  * Tests that params view shows params when they exist, and are hidden otherwise.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, $$, EVENTS, WebAudioInspectorView } = panelWin;
-  let gVars = WebAudioInspectorView._propsView;
+  let { gFront, $, $$, EVENTS, InspectorView } = panelWin;
+  let gVars = InspectorView._propsView;
 
   let started = once(gFront, "start-context");
 
   reload(target);
 
   let [actors] = yield Promise.all([
     get3(gFront, "create-node"),
     waitForGraphRendered(panelWin, 3, 2)
--- a/browser/devtools/webaudioeditor/test/browser_wa_reset-03.js
+++ b/browser/devtools/webaudioeditor/test/browser_wa_reset-03.js
@@ -4,46 +4,46 @@
 /**
  * Tests reloading a tab with the tools open properly cleans up
  * the inspector and selected node.
  */
 
 function spawnTest() {
   let [target, debuggee, panel] = yield initWebAudioEditor(SIMPLE_CONTEXT_URL);
   let { panelWin } = panel;
-  let { gFront, $, WebAudioInspectorView } = panelWin;
+  let { gFront, $, InspectorView } = panelWin;
 
   reload(target);
 
   let [actors] = yield Promise.all([
     get3(gFront, "create-node"),
     waitForGraphRendered(panelWin, 3, 2)
   ]);
   let nodeIds = actors.map(actor => actor.actorID);
 
   yield clickGraphNode(panelWin, nodeIds[1], true);
-  ok(WebAudioInspectorView.isVisible(), "InspectorView visible after selecting a node.");
-  is(WebAudioInspectorView.getCurrentAudioNode().id, nodeIds[1], "InspectorView has correct node set.");
+  ok(InspectorView.isVisible(), "InspectorView visible after selecting a node.");
+  is(InspectorView.getCurrentAudioNode().id, nodeIds[1], "InspectorView has correct node set.");
 
   /**
    * Reload
    */
 
   reload(target);
 
   [actors] = yield Promise.all([
     get3(gFront, "create-node"),
     waitForGraphRendered(panelWin, 3, 2)
   ]);
   nodeIds = actors.map(actor => actor.actorID);
 
-  ok(!WebAudioInspectorView.isVisible(), "InspectorView hidden on start.");
-  ise(WebAudioInspectorView.getCurrentAudioNode(), null,
+  ok(!InspectorView.isVisible(), "InspectorView hidden on start.");
+  ise(InspectorView.getCurrentAudioNode(), null,
     "InspectorView has no current node set on reset.");
 
   yield clickGraphNode(panelWin, nodeIds[2], true);
-  ok(WebAudioInspectorView.isVisible(),
+  ok(InspectorView.isVisible(),
     "InspectorView visible after selecting a node after a reset.");
-  is(WebAudioInspectorView.getCurrentAudioNode().id, nodeIds[2], "InspectorView has correct node set upon clicking graph node after a reset.");
+  is(InspectorView.getCurrentAudioNode().id, nodeIds[2], "InspectorView has correct node set upon clicking graph node after a reset.");
 
   yield teardown(panel);
   finish();
 }
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/test/doc_connect-toggle-param.html
@@ -0,0 +1,27 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>Web Audio Editor test page</title>
+  </head>
+
+  <body>
+
+    <script type="text/javascript;version=1.8">
+      "use strict";
+
+      let i = 0;
+      let ctx = new AudioContext();
+      let osc = ctx.createOscillator();
+      let gain = ctx.createGain();
+      gain.gain.value = 0;
+      gain.connect(ctx.destination);
+      osc.start(0);
+      setTimeout(() => osc.connect(gain.gain), 500);
+    </script>
+  </body>
+
+</html>
--- a/browser/devtools/webaudioeditor/test/head.js
+++ b/browser/devtools/webaudioeditor/test/head.js
@@ -23,26 +23,30 @@ let TargetFactory = devtools.TargetFacto
 const EXAMPLE_URL = "http://example.com/browser/browser/devtools/webaudioeditor/test/";
 const SIMPLE_CONTEXT_URL = EXAMPLE_URL + "doc_simple-context.html";
 const COMPLEX_CONTEXT_URL = EXAMPLE_URL + "doc_complex-context.html";
 const SIMPLE_NODES_URL = EXAMPLE_URL + "doc_simple-node-creation.html";
 const MEDIA_NODES_URL = EXAMPLE_URL + "doc_media-node-creation.html";
 const BUFFER_AND_ARRAY_URL = EXAMPLE_URL + "doc_buffer-and-array.html";
 const DESTROY_NODES_URL = EXAMPLE_URL + "doc_destroy-nodes.html";
 const CONNECT_TOGGLE_URL = EXAMPLE_URL + "doc_connect-toggle.html";
+const CONNECT_TOGGLE_PARAM_URL = EXAMPLE_URL + "doc_connect-toggle-param.html";
 const CONNECT_PARAM_URL = EXAMPLE_URL + "doc_connect-param.html";
 const CONNECT_MULTI_PARAM_URL = EXAMPLE_URL + "doc_connect-multi-param.html";
 const IFRAME_CONTEXT_URL = EXAMPLE_URL + "doc_iframe-context.html";
 
 // All tests are asynchronous.
 waitForExplicitFinish();
 
 let gToolEnabled = Services.prefs.getBoolPref("devtools.webaudioeditor.enabled");
 
+gDevTools.testing = true;
+
 registerCleanupFunction(() => {
+  gDevTools.testing = false;
   info("finish() was called, cleaning up...");
   Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging);
   Services.prefs.setBoolPref("devtools.webaudioeditor.enabled", gToolEnabled);
   Cu.forceGC();
 });
 
 function addTab(aUrl, aWindow) {
   info("Adding tab: " + aUrl);
@@ -205,20 +209,17 @@ function getNSpread (front, eventName, c
  * Waits for the UI_GRAPH_RENDERED event to fire, but only
  * resolves when the graph was rendered with the correct count of
  * nodes and edges.
  */
 function waitForGraphRendered (front, nodeCount, edgeCount, paramEdgeCount) {
   let deferred = Promise.defer();
   let eventName = front.EVENTS.UI_GRAPH_RENDERED;
   front.on(eventName, function onGraphRendered (_, nodes, edges, pEdges) {
-    info(nodes);
-    info(edges)
-    info(pEdges);
-    let paramEdgesDone = paramEdgeCount ? paramEdgeCount === pEdges : true;
+    let paramEdgesDone = paramEdgeCount != null ? paramEdgeCount === pEdges : true;
     if (nodes === nodeCount && edges === edgeCount && paramEdgesDone) {
       front.off(eventName, onGraphRendered);
       deferred.resolve();
     }
   });
   return deferred.promise;
 }
 
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/views/context.js
@@ -0,0 +1,305 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { debounce } = require("sdk/lang/functional");
+
+// Globals for d3 stuff
+// Default properties of the graph on rerender
+const GRAPH_DEFAULTS = {
+  translate: [20, 20],
+  scale: 1
+};
+
+// Sizes of SVG arrows in graph
+const ARROW_HEIGHT = 5;
+const ARROW_WIDTH = 8;
+
+// Styles for markers as they cannot be done with CSS.
+const MARKER_STYLING = {
+  light: "#AAA",
+  dark: "#CED3D9"
+};
+
+const GRAPH_DEBOUNCE_TIMER = 100;
+
+// `gAudioNodes` events that should require the graph
+// to redraw
+const GRAPH_REDRAW_EVENTS = ["add", "connect", "disconnect", "remove"];
+
+/**
+ * Functions handling the graph UI.
+ */
+let ContextView = {
+  /**
+   * Initialization function, called when the tool is started.
+   */
+  initialize: function() {
+    this._onGraphNodeClick = this._onGraphNodeClick.bind(this);
+    this._onThemeChange = this._onThemeChange.bind(this);
+    this._onNodeSelect = this._onNodeSelect.bind(this);
+    this._onStartContext = this._onStartContext.bind(this);
+    this._onEvent = this._onEvent.bind(this);
+
+    this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER);
+    $('#graph-target').addEventListener('click', this._onGraphNodeClick, false);
+
+    window.on(EVENTS.THEME_CHANGE, this._onThemeChange);
+    window.on(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect);
+    window.on(EVENTS.START_CONTEXT, this._onStartContext);
+    gAudioNodes.on("*", this._onEvent);
+  },
+
+  /**
+   * Destruction function, called when the tool is closed.
+   */
+  destroy: function() {
+    // If the graph was rendered at all, then the handler
+    // for zooming in will be set. We must remove it to prevent leaks.
+    if (this._zoomBinding) {
+      this._zoomBinding.on("zoom", null);
+    }
+    $('#graph-target').removeEventListener('click', this._onGraphNodeClick, false);
+    window.off(EVENTS.THEME_CHANGE, this._onThemeChange);
+    window.off(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect);
+    window.off(EVENTS.START_CONTEXT, this._onStartContext);
+    gAudioNodes.off("*", this._onEvent);
+  },
+
+  /**
+   * Called when a page is reloaded and waiting for a "start-context" event
+   * and clears out old content
+   */
+  resetUI: function () {
+    this.clearGraph();
+    this.resetGraphTransform();
+  },
+
+  /**
+   * Clears out the rendered graph, called when resetting the SVG elements to draw again,
+   * or when resetting the entire UI tool
+   */
+  clearGraph: function () {
+    $("#graph-target").innerHTML = "";
+  },
+
+  /**
+   * Moves the graph back to its original scale and translation.
+   */
+  resetGraphTransform: function () {
+    // Only reset if the graph was ever drawn.
+    if (this._zoomBinding) {
+      let { translate, scale } = GRAPH_DEFAULTS;
+      // Must set the `zoomBinding` so the next `zoom` event is in sync with
+      // where the graph is visually (set by the `transform` attribute).
+      this._zoomBinding.scale(scale);
+      this._zoomBinding.translate(translate);
+      d3.select("#graph-target")
+        .attr("transform", "translate(" + translate + ") scale(" + scale + ")");
+    }
+  },
+
+  getCurrentScale: function () {
+    return this._zoomBinding ? this._zoomBinding.scale() : null;
+  },
+
+  getCurrentTranslation: function () {
+    return this._zoomBinding ? this._zoomBinding.translate() : null;
+  },
+
+  /**
+   * Makes the corresponding graph node appear "focused", removing
+   * focused styles from all other nodes. If no `actorID` specified,
+   * make all nodes appear unselected.
+   * Called from UI_INSPECTOR_NODE_SELECT.
+   */
+  focusNode: function (actorID) {
+    // Remove class "selected" from all nodes
+    Array.forEach($$(".nodes > g"), $node => $node.classList.remove("selected"));
+    // Add to "selected"
+    if (actorID) {
+      this._getNodeByID(actorID).classList.add("selected");
+    }
+  },
+
+  /**
+   * Takes an actorID and returns the corresponding DOM SVG element in the graph
+   */
+  _getNodeByID: function (actorID) {
+    return $(".nodes > g[data-id='" + actorID + "']");
+  },
+
+  /**
+   * This method renders the nodes currently available in `gAudioNodes` and is
+   * throttled to be called at most every `GRAPH_DEBOUNCE_TIMER` milliseconds.
+   * It's called whenever the audio context routing changes, after being debounced.
+   */
+  draw: function () {
+    // Clear out previous SVG information
+    this.clearGraph();
+
+    let graph = new dagreD3.Digraph();
+    let renderer = new dagreD3.Renderer();
+    gAudioNodes.populateGraph(graph);
+
+    // Post-render manipulation of the nodes
+    let oldDrawNodes = renderer.drawNodes();
+    renderer.drawNodes(function(graph, root) {
+      let svgNodes = oldDrawNodes(graph, root);
+      svgNodes.attr("class", (n) => {
+        let node = graph.node(n);
+        return "audionode type-" + node.type;
+      });
+      svgNodes.attr("data-id", (n) => {
+        let node = graph.node(n);
+        return node.id;
+      });
+      return svgNodes;
+    });
+
+    // Post-render manipulation of edges
+    // TODO do all of this more efficiently, rather than
+    // using the direct D3 helper utilities to loop over each
+    // edge several times
+    let oldDrawEdgePaths = renderer.drawEdgePaths();
+    renderer.drawEdgePaths(function(graph, root) {
+      let svgEdges = oldDrawEdgePaths(graph, root);
+      svgEdges.attr("data-source", (n) => {
+        let edge = graph.edge(n);
+        return edge.source;
+      });
+      svgEdges.attr("data-target", (n) => {
+        let edge = graph.edge(n);
+        return edge.target;
+      });
+      svgEdges.attr("data-param", (n) => {
+        let edge = graph.edge(n);
+        return edge.param ? edge.param : null;
+      });
+      // We have to manually specify the default classes on the edges
+      // as to not overwrite them
+      let defaultClasses = "edgePath enter";
+      svgEdges.attr("class", (n) => {
+        let edge = graph.edge(n);
+        return defaultClasses + (edge.param ? (" param-connection " + edge.param) : "");
+      });
+
+      return svgEdges;
+    });
+
+    // Override Dagre-d3's post render function by passing in our own.
+    // This way we can leave styles out of it.
+    renderer.postRender((graph, root) => {
+      // We have to manually set the marker styling since we cannot
+      // do this currently with CSS, although it is in spec for SVG2
+      // https://svgwg.org/svg2-draft/painting.html#VertexMarkerProperties
+      // For now, manually set it on creation, and the `_onThemeChange`
+      // function will fire when the devtools theme changes to update the
+      // styling manually.
+      let theme = Services.prefs.getCharPref("devtools.theme");
+      let markerColor = MARKER_STYLING[theme];
+      if (graph.isDirected() && root.select("#arrowhead").empty()) {
+        root
+          .append("svg:defs")
+          .append("svg:marker")
+          .attr("id", "arrowhead")
+          .attr("viewBox", "0 0 10 10")
+          .attr("refX", ARROW_WIDTH)
+          .attr("refY", ARROW_HEIGHT)
+          .attr("markerUnits", "strokewidth")
+          .attr("markerWidth", ARROW_WIDTH)
+          .attr("markerHeight", ARROW_HEIGHT)
+          .attr("orient", "auto")
+          .attr("style", "fill: " + markerColor)
+          .append("svg:path")
+          .attr("d", "M 0 0 L 10 5 L 0 10 z");
+      }
+
+      // Reselect the previously selected audio node
+      let currentNode = InspectorView.getCurrentAudioNode();
+      if (currentNode) {
+        this.focusNode(currentNode.id);
+      }
+
+      // Fire an event upon completed rendering, with extra information
+      // if in testing mode only.
+      let info = {};
+      if (gDevTools.testing) {
+        info = gAudioNodes.getInfo();
+      }
+      window.emit(EVENTS.UI_GRAPH_RENDERED, info.nodes, info.edges, info.paramEdges);
+    });
+
+    let layout = dagreD3.layout().rankDir("LR");
+    renderer.layout(layout).run(graph, d3.select("#graph-target"));
+
+    // Handle the sliding and zooming of the graph,
+    // store as `this._zoomBinding` so we can unbind during destruction
+    if (!this._zoomBinding) {
+      this._zoomBinding = d3.behavior.zoom().on("zoom", function () {
+        var ev = d3.event;
+        d3.select("#graph-target")
+          .attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")");
+      });
+      d3.select("svg").call(this._zoomBinding);
+
+      // Set initial translation and scale -- this puts D3's awareness of
+      // the graph in sync with what the user sees originally.
+      this.resetGraphTransform();
+    }
+  },
+
+  /**
+   * Event handlers
+   */
+
+  /**
+   * Called once "start-context" is fired, indicating that there is an audio
+   * context being created to view so render the graph.
+   */
+  _onStartContext: function () {
+    this.draw();
+  },
+
+  /**
+   * Called when `gAudioNodes` fires an event -- most events (listed
+   * in GRAPH_REDRAW_EVENTS) qualify as a redraw event.
+   */
+  _onEvent: function (eventName, ...args) {
+    if (~GRAPH_REDRAW_EVENTS.indexOf(eventName)) {
+      this.draw();
+    }
+  },
+
+  _onNodeSelect: function (eventName, id) {
+    this.focusNode(id);
+  },
+
+  /**
+   * Fired when the devtools theme changes.
+   */
+  _onThemeChange: function (eventName, theme) {
+    let markerColor = MARKER_STYLING[theme];
+    let marker = $("#arrowhead");
+    if (marker) {
+      marker.setAttribute("style", "fill: " + markerColor);
+    }
+  },
+
+  /**
+   * Fired when a node in the svg graph is clicked. Used to handle triggering the AudioNodePane.
+   *
+   * @param Event e
+   *        Click event.
+   */
+  _onGraphNodeClick: function (e) {
+    let node = findGraphNodeParent(e.target);
+    // If node not found (clicking outside of an audio node in the graph),
+    // then ignore this event
+    if (!node)
+      return;
+
+    window.emit(EVENTS.UI_SELECT_NODE, node.getAttribute("data-id"));
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/views/inspector.js
@@ -0,0 +1,240 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+Cu.import("resource:///modules/devtools/VariablesView.jsm");
+Cu.import("resource:///modules/devtools/VariablesViewController.jsm");
+
+// Strings for rendering
+const EXPAND_INSPECTOR_STRING = L10N.getStr("expandInspector");
+const COLLAPSE_INSPECTOR_STRING = L10N.getStr("collapseInspector");
+
+// Store width as a preference rather than hardcode
+// TODO bug 1009056
+const INSPECTOR_WIDTH = 300;
+
+const GENERIC_VARIABLES_VIEW_SETTINGS = {
+  searchEnabled: false,
+  editableValueTooltip: "",
+  editableNameTooltip: "",
+  preventDisableOnChange: true,
+  preventDescriptorModifiers: false,
+  eval: () => {}
+};
+
+/**
+ * Functions handling the audio node inspector UI.
+ */
+
+let InspectorView = {
+  _currentNode: null,
+
+  // Set up config for view toggling
+  _collapseString: COLLAPSE_INSPECTOR_STRING,
+  _expandString: EXPAND_INSPECTOR_STRING,
+  _toggleEvent: EVENTS.UI_INSPECTOR_TOGGLED,
+  _animated: true,
+  _delayed: true,
+
+  /**
+   * Initialization function called when the tool starts up.
+   */
+  initialize: function () {
+    this._tabsPane = $("#web-audio-editor-tabs");
+
+    // Set up view controller
+    this.el = $("#web-audio-inspector");
+    this.el.setAttribute("width", INSPECTOR_WIDTH);
+    this.button = $("#inspector-pane-toggle");
+    mixin(this, ToggleMixin);
+    this.bindToggle();
+
+    // Hide inspector view on startup
+    this.hideImmediately();
+
+    this._onEval = this._onEval.bind(this);
+    this._onNodeSelect = this._onNodeSelect.bind(this);
+    this._onDestroyNode = this._onDestroyNode.bind(this);
+
+    this._propsView = new VariablesView($("#properties-tabpanel-content"), GENERIC_VARIABLES_VIEW_SETTINGS);
+    this._propsView.eval = this._onEval;
+
+    window.on(EVENTS.UI_SELECT_NODE, this._onNodeSelect);
+    gAudioNodes.on("remove", this._onDestroyNode);
+  },
+
+  /**
+   * Destruction function called when the tool cleans up.
+   */
+  destroy: function () {
+    this.unbindToggle();
+    window.off(EVENTS.UI_SELECT_NODE, this._onNodeSelect);
+    gAudioNodes.off("remove", this._onDestroyNode);
+
+    this.el = null;
+    this.button = null;
+    this._tabsPane = null;
+  },
+
+  /**
+   * Takes a AudioNodeView `node` and sets it as the current
+   * node and scaffolds the inspector view based off of the new node.
+   */
+  setCurrentAudioNode: function (node) {
+    this._currentNode = node || null;
+
+    // If no node selected, set the inspector back to "no AudioNode selected"
+    // view.
+    if (!node) {
+      $("#web-audio-editor-details-pane-empty").removeAttribute("hidden");
+      $("#web-audio-editor-tabs").setAttribute("hidden", "true");
+      window.emit(EVENTS.UI_INSPECTOR_NODE_SET, null);
+    }
+    // Otherwise load up the tabs view and hide the empty placeholder
+    else {
+      $("#web-audio-editor-details-pane-empty").setAttribute("hidden", "true");
+      $("#web-audio-editor-tabs").removeAttribute("hidden");
+      this._setTitle();
+      this._buildPropertiesView()
+        .then(() => window.emit(EVENTS.UI_INSPECTOR_NODE_SET, this._currentNode.id));
+    }
+  },
+
+  /**
+   * Returns the current AudioNodeView.
+   */
+  getCurrentAudioNode: function () {
+    return this._currentNode;
+  },
+
+  /**
+   * Empties out the props view.
+   */
+  resetUI: function () {
+    this._propsView.empty();
+    // Set current node to empty to load empty view
+    this.setCurrentAudioNode();
+
+    // Reset AudioNode inspector and hide
+    this.hideImmediately();
+  },
+
+  /**
+   * Sets the title of the Inspector view
+   */
+  _setTitle: function () {
+    let node = this._currentNode;
+    let title = node.type.replace(/Node$/, "");
+    $("#web-audio-inspector-title").setAttribute("value", title);
+  },
+
+  /**
+   * Reconstructs the `Properties` tab in the inspector
+   * with the `this._currentNode` as it's source.
+   */
+  _buildPropertiesView: Task.async(function* () {
+    let propsView = this._propsView;
+    let node = this._currentNode;
+    propsView.empty();
+
+    let audioParamsScope = propsView.addScope("AudioParams");
+    let props = yield node.getParams();
+
+    // Disable AudioParams VariableView expansion
+    // when there are no props i.e. AudioDestinationNode
+    this._togglePropertiesView(!!props.length);
+
+    props.forEach(({ param, value, flags }) => {
+      let descriptor = {
+        value: value,
+        writable: !flags || !flags.readonly,
+      };
+      audioParamsScope.addItem(param, descriptor);
+    });
+
+    audioParamsScope.expanded = true;
+
+    window.emit(EVENTS.UI_PROPERTIES_TAB_RENDERED, node.id);
+  }),
+
+  _togglePropertiesView: function (show) {
+    let propsView = $("#properties-tabpanel-content");
+    let emptyView = $("#properties-tabpanel-content-empty");
+    (show ? propsView : emptyView).removeAttribute("hidden");
+    (show ? emptyView : propsView).setAttribute("hidden", "true");
+  },
+
+  /**
+   * Returns the scope for AudioParams in the
+   * VariablesView.
+   *
+   * @return Scope
+   */
+  _getAudioPropertiesScope: function () {
+    return this._propsView.getScopeAtIndex(0);
+  },
+
+  /**
+   * Event handlers
+   */
+
+  /**
+   * Executed when an audio prop is changed in the UI.
+   */
+  _onEval: Task.async(function* (variable, value) {
+    let ownerScope = variable.ownerView;
+    let node = this._currentNode;
+    let propName = variable.name;
+    let error;
+
+    if (!variable._initialDescriptor.writable) {
+      error = new Error("Variable " + propName + " is not writable.");
+    } else {
+      // Cast value to proper type
+      try {
+        let number = parseFloat(value);
+        if (!isNaN(number)) {
+          value = number;
+        } else {
+          value = JSON.parse(value);
+        }
+        error = yield node.actor.setParam(propName, value);
+      }
+      catch (e) {
+        error = e;
+      }
+    }
+
+    // TODO figure out how to handle and display set prop errors
+    // and enable `test/brorwser_wa_properties-view-edit.js`
+    // Bug 994258
+    if (!error) {
+      ownerScope.get(propName).setGrip(value);
+      window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value);
+    } else {
+      window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value);
+    }
+  }),
+
+  /**
+   * Called on EVENTS.UI_SELECT_NODE, and takes an actorID `id`
+   * and calls `setCurrentAudioNode` to scaffold the inspector view.
+   */
+  _onNodeSelect: function (_, id) {
+    this.setCurrentAudioNode(gAudioNodes.get(id));
+
+    // Ensure inspector is visible when selecting a new node
+    this.show();
+  },
+
+  /**
+   * Called when `DESTROY_NODE` is fired to remove the node from props view if
+   * it's currently selected.
+   */
+  _onDestroyNode: function (node) {
+    if (this._currentNode && this._currentNode.id === node.id) {
+      this.setCurrentAudioNode(null);
+    }
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webaudioeditor/views/utils.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Takes an element in an SVG graph and iterates over
+ * ancestors until it finds the graph node container. If not found,
+ * returns null.
+ */
+
+function findGraphNodeParent (el) {
+  // Some targets may not contain `classList` property
+  if (!el.classList)
+    return null;
+
+  while (!el.classList.contains("nodes")) {
+    if (el.classList.contains("audionode"))
+      return el;
+    else
+      el = el.parentNode;
+  }
+  return null;
+}
+
+/**
+ * Object for use with `mix` into a view.
+ * Must have the following properties defined on the view:
+ * - `el`
+ * - `button`
+ * - `_collapseString`
+ * - `_expandString`
+ * - `_toggleEvent`
+ *
+ * Optional properties on the view can be defined to specify default
+ * visibility options.
+ * - `_animated`
+ * - `_delayed`
+ */
+let ToggleMixin = {
+
+  bindToggle: function () {
+    this._onToggle = this._onToggle.bind(this);
+    this.button.addEventListener("mousedown", this._onToggle, false);
+  },
+
+  unbindToggle: function () {
+    this.button.removeEventListener("mousedown", this._onToggle);
+  },
+
+  show: function () {
+    this._viewController({ visible: true });
+  },
+
+  hide: function () {
+    this._viewController({ visible: false });
+  },
+
+  hideImmediately: function () {
+    this._viewController({ visible: false, delayed: false, animated: false });
+  },
+
+  /**
+   * Returns a boolean indicating whether or not the view.
+   * is currently being shown.
+   */
+  isVisible: function () {
+    return !this.el.hasAttribute("pane-collapsed");
+  },
+
+  /**
+   * Toggles the visibility of the view.
+   *
+   * @param object visible
+   *        - visible: boolean indicating whether the panel should be shown or not
+   *        - animated: boolean indiciating whether the pane should be animated
+   *        - delayed: boolean indicating whether the pane's opening should wait
+   *                   a few cycles or not
+   */
+  _viewController: function ({ visible, animated, delayed }) {
+    let flags = {
+      visible: visible,
+      animated: animated != null ? animated : !!this._animated,
+      delayed: delayed != null ? delayed : !!this._delayed,
+      callback: () => window.emit(this._toggleEvent, visible)
+    };
+
+    ViewHelpers.togglePane(flags, this.el);
+
+    if (flags.visible) {
+      this.button.removeAttribute("pane-collapsed");
+      this.button.setAttribute("tooltiptext", this._collapseString);
+    }
+    else {
+      this.button.setAttribute("pane-collapsed", "");
+      this.button.setAttribute("tooltiptext", this._expandString);
+    }
+  },
+
+  _onToggle: function () {
+    this._viewController({ visible: !this.isVisible() });
+  }
+}
deleted file mode 100644
--- a/browser/devtools/webaudioeditor/webaudioeditor-controller.js
+++ /dev/null
@@ -1,428 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this file,
- * You can obtain one at http://mozilla.org/MPL/2.0/. */
-"use strict";
-
-const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
-
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
-Cu.import("resource:///modules/devtools/gDevTools.jsm");
-
-// Override DOM promises with Promise.jsm helpers
-const { defer, all } = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
-
-const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
-const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
-const EventEmitter = require("devtools/toolkit/event-emitter");
-const STRINGS_URI = "chrome://browser/locale/devtools/webaudioeditor.properties"
-const L10N = new ViewHelpers.L10N(STRINGS_URI);
-const Telemetry = require("devtools/shared/telemetry");
-const telemetry = new Telemetry();
-
-let { console } = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
-
-// The panel's window global is an EventEmitter firing the following events:
-const EVENTS = {
-  // Fired when the first AudioNode has been created, signifying
-  // that the AudioContext is being used and should be tracked via the editor.
-  START_CONTEXT: "WebAudioEditor:StartContext",
-
-  // On node creation, connect and disconnect.
-  CREATE_NODE: "WebAudioEditor:CreateNode",
-  CONNECT_NODE: "WebAudioEditor:ConnectNode",
-  DISCONNECT_NODE: "WebAudioEditor:DisconnectNode",
-
-  // When a node gets GC'd.
-  DESTROY_NODE: "WebAudioEditor:DestroyNode",
-
-  // On a node parameter's change.
-  CHANGE_PARAM: "WebAudioEditor:ChangeParam",
-
-  // When the devtools theme changes.
-  THEME_CHANGE: "WebAudioEditor:ThemeChange",
-
-  // When the UI is reset from tab navigation.
-  UI_RESET: "WebAudioEditor:UIReset",
-
-  // When a param has been changed via the UI and successfully
-  // pushed via the actor to the raw audio node.
-  UI_SET_PARAM: "WebAudioEditor:UISetParam",
-
-  // When a node is to be set in the InspectorView.
-  UI_SELECT_NODE: "WebAudioEditor:UISelectNode",
-
-  // When the inspector is finished setting a new node.
-  UI_INSPECTOR_NODE_SET: "WebAudioEditor:UIInspectorNodeSet",
-
-  // When the inspector is finished rendering in or out of view.
-  UI_INSPECTOR_TOGGLED: "WebAudioEditor:UIInspectorToggled",
-
-  // When an audio node is finished loading in the Properties tab.
-  UI_PROPERTIES_TAB_RENDERED: "WebAudioEditor:UIPropertiesTabRendered",
-
-  // When the Audio Context graph finishes rendering.
-  // Is called with two arguments, first representing number of nodes
-  // rendered, second being the number of edge connections rendering (not counting
-  // param edges), followed by the count of the param edges rendered.
-  UI_GRAPH_RENDERED: "WebAudioEditor:UIGraphRendered"
-};
-
-/**
- * The current target and the Web Audio Editor front, set by this tool's host.
- */
-let gToolbox, gTarget, gFront;
-
-/**
- * Track an array of audio nodes
- */
-let AudioNodes = [];
-let AudioNodeConnections = new WeakMap(); // <AudioNodeView, Set<AudioNodeView>>
-let AudioParamConnections = new WeakMap(); // <AudioNodeView, Object>
-
-// Light representation wrapping an AudioNode actor with additional properties
-function AudioNodeView (actor) {
-  this.actor = actor;
-  this.id = actor.actorID;
-}
-
-// A proxy for the underlying AudioNodeActor to fetch its type
-// and subsequently assign the type to the instance.
-AudioNodeView.prototype.getType = Task.async(function* () {
-  this.type = yield this.actor.getType();
-  return this.type;
-});
-
-// Helper method to create connections in the AudioNodeConnections
-// WeakMap for rendering. Returns a boolean indicating
-// if the connection was successfully created. Will return `false`
-// when the connection was previously made.
-AudioNodeView.prototype.connect = function (destination) {
-  let connections = AudioNodeConnections.get(this) || new Set();
-  AudioNodeConnections.set(this, connections);
-
-  // Don't duplicate add.
-  if (!connections.has(destination)) {
-    connections.add(destination);
-    return true;
-  }
-  return false;
-};
-
-// Helper method to create connections in the AudioNodeConnections
-// WeakMap for rendering. Returns a boolean indicating
-// if the connection was successfully created. Will return `false`
-// when the connection was previously made.
-AudioNodeView.prototype.connectParam = function (destination, param) {
-  let connections = AudioParamConnections.get(this) || {};
-  AudioParamConnections.set(this, connections);
-
-  let params = connections[destination.id] = connections[destination.id] || [];
-
-  if (!~params.indexOf(param)) {
-    params.push(param);
-    return true;
-  }
-  return false;
-};
-
-// Helper method to remove audio connections from the current AudioNodeView
-AudioNodeView.prototype.disconnect = function () {
-  AudioNodeConnections.set(this, new Set());
-  AudioParamConnections.set(this, {});
-};
-
-// Returns a promise that resolves to an array of objects containing
-// both a `param` name property and a `value` property.
-AudioNodeView.prototype.getParams = function () {
-  return this.actor.getParams();
-};
-
-
-/**
- * Initializes the web audio editor views
- */
-function startupWebAudioEditor() {
-  return all([
-    WebAudioEditorController.initialize(),
-    WebAudioGraphView.initialize(),
-    WebAudioInspectorView.initialize(),
-  ]);
-}
-
-/**
- * Destroys the web audio editor controller and views.
- */
-function shutdownWebAudioEditor() {
-  return all([
-    WebAudioEditorController.destroy(),
-    WebAudioGraphView.destroy(),
-    WebAudioInspectorView.destroy(),
-  ]);
-}
-
-/**
- * Functions handling target-related lifetime events.
- */
-let WebAudioEditorController = {
-  /**
-   * Listen for events emitted by the current tab target.
-   */
-  initialize: function() {
-    telemetry.toolOpened("webaudioeditor");
-    this._onTabNavigated = this._onTabNavigated.bind(this);
-    this._onThemeChange = this._onThemeChange.bind(this);
-    gTarget.on("will-navigate", this._onTabNavigated);
-    gTarget.on("navigate", this._onTabNavigated);
-    gFront.on("start-context", this._onStartContext);
-    gFront.on("create-node", this._onCreateNode);
-    gFront.on("connect-node", this._onConnectNode);
-    gFront.on("connect-param", this._onConnectParam);
-    gFront.on("disconnect-node", this._onDisconnectNode);
-    gFront.on("change-param", this._onChangeParam);
-    gFront.on("destroy-node", this._onDestroyNode);
-
-    // Hook into theme change so we can change
-    // the graph's marker styling, since we can't do this
-    // with CSS
-    gDevTools.on("pref-changed", this._onThemeChange);
-
-    // Set up events to refresh the Graph view
-    window.on(EVENTS.CREATE_NODE, this._onUpdatedContext);
-    window.on(EVENTS.CONNECT_NODE, this._onUpdatedContext);
-    window.on(EVENTS.DISCONNECT_NODE, this._onUpdatedContext);
-    window.on(EVENTS.DESTROY_NODE, this._onUpdatedContext);
-    window.on(EVENTS.CONNECT_PARAM, this._onUpdatedContext);
-  },
-
-  /**
-   * Remove events emitted by the current tab target.
-   */
-  destroy: function() {
-    telemetry.toolClosed("webaudioeditor");
-    gTarget.off("will-navigate", this._onTabNavigated);
-    gTarget.off("navigate", this._onTabNavigated);
-    gFront.off("start-context", this._onStartContext);
-    gFront.off("create-node", this._onCreateNode);
-    gFront.off("connect-node", this._onConnectNode);
-    gFront.off("connect-param", this._onConnectParam);
-    gFront.off("disconnect-node", this._onDisconnectNode);
-    gFront.off("change-param", this._onChangeParam);
-    gFront.off("destroy-node", this._onDestroyNode);
-    window.off(EVENTS.CREATE_NODE, this._onUpdatedContext);
-    window.off(EVENTS.CONNECT_NODE, this._onUpdatedContext);
-    window.off(EVENTS.DISCONNECT_NODE, this._onUpdatedContext);
-    window.off(EVENTS.DESTROY_NODE, this._onUpdatedContext);
-    window.off(EVENTS.CONNECT_PARAM, this._onUpdatedContext);
-    gDevTools.off("pref-changed", this._onThemeChange);
-  },
-
-  /**
-   * Called when page is reloaded to show the reload notice and waiting
-   * for an audio context notice.
-   */
-  reset: function () {
-    $("#content").hidden = true;
-    WebAudioGraphView.resetUI();
-    WebAudioInspectorView.resetUI();
-  },
-
-  /**
-   * Called when a new audio node is created, or the audio context
-   * routing changes.
-   */
-  _onUpdatedContext: function () {
-    WebAudioGraphView.draw();
-  },
-
-  /**
-   * Fired when the devtools theme changes (light, dark, etc.)
-   * so that the graph can update marker styling, as that
-   * cannot currently be done with CSS.
-   */
-  _onThemeChange: function (event, data) {
-    window.emit(EVENTS.THEME_CHANGE, data.newValue);
-  },
-
-  /**
-   * Called for each location change in the debugged tab.
-   */
-  _onTabNavigated: Task.async(function* (event, {isFrameSwitching}) {
-    switch (event) {
-      case "will-navigate": {
-        // Make sure the backend is prepared to handle audio contexts.
-        if (!isFrameSwitching) {
-          yield gFront.setup({ reload: false });
-        }
-
-        // Clear out current UI.
-        this.reset();
-
-        // When switching to an iframe, ensure displaying the reload button.
-        // As the document has already been loaded without being hooked.
-        if (isFrameSwitching) {
-          $("#reload-notice").hidden = false;
-          $("#waiting-notice").hidden = true;
-        } else {
-          // Otherwise, we are loading a new top level document,
-          // so we don't need to reload anymore and should receive
-          // new node events.
-          $("#reload-notice").hidden = true;
-          $("#waiting-notice").hidden = false;
-        }
-
-        // Clear out stored audio nodes
-        AudioNodes.length = 0;
-        AudioNodeConnections.clear();
-        window.emit(EVENTS.UI_RESET);
-        break;
-      }
-      case "navigate": {
-        // TODO Case of bfcache, needs investigating
-        // bug 994250
-        break;
-      }
-    }
-  }),
-
-  /**
-   * Called after the first audio node is created in an audio context,
-   * signaling that the audio context is being used.
-   */
-  _onStartContext: function() {
-    $("#reload-notice").hidden = true;
-    $("#waiting-notice").hidden = true;
-    $("#content").hidden = false;
-    window.emit(EVENTS.START_CONTEXT);
-  },
-
-  /**
-   * Called when a new node is created. Creates an `AudioNodeView` instance
-   * for tracking throughout the editor.
-   */
-  _onCreateNode: Task.async(function* (nodeActor) {
-    let node = new AudioNodeView(nodeActor);
-    yield node.getType();
-    AudioNodes.push(node);
-    window.emit(EVENTS.CREATE_NODE, node.id);
-  }),
-
-  /**
-   * Called on `destroy-node` when an AudioNode is GC'd. Removes
-   * from the AudioNode array and fires an event indicating the removal.
-   */
-  _onDestroyNode: function (nodeActor) {
-    for (let i = 0; i < AudioNodes.length; i++) {
-      if (equalActors(AudioNodes[i].actor, nodeActor)) {
-        AudioNodes.splice(i, 1);
-        window.emit(EVENTS.DESTROY_NODE, nodeActor.actorID);
-        break;
-      }
-    }
-  },
-
-  /**
-   * Called when a node is connected to another node.
-   */
-  _onConnectNode: Task.async(function* ({ source: sourceActor, dest: destActor }) {
-    let [source, dest] = yield waitForNodeCreation(sourceActor, destActor);
-
-    // Connect nodes, and only emit if it's a new connection.
-    if (source.connect(dest)) {
-      window.emit(EVENTS.CONNECT_NODE, source.id, dest.id);
-    }
-  }),
-
-  /**
-   * Called when a node is conneceted to another node's AudioParam.
-   */
-  _onConnectParam: Task.async(function* ({ source: sourceActor, dest: destActor, param }) {
-    let [source, dest] = yield waitForNodeCreation(sourceActor, destActor);
-
-    if (source.connectParam(dest, param)) {
-      window.emit(EVENTS.CONNECT_PARAM, source.id, dest.id, param);
-    }
-  }),
-
-  /**
-   * Called when a node is disconnected.
-   */
-  _onDisconnectNode: function(nodeActor) {
-    let node = getViewNodeByActor(nodeActor);
-    node.disconnect();
-    window.emit(EVENTS.DISCONNECT_NODE, node.id);
-  },
-
-  /**
-   * Called when a node param is changed.
-   */
-  _onChangeParam: function({ actor, param, value }) {
-    window.emit(EVENTS.CHANGE_PARAM, getViewNodeByActor(actor), param, value);
-  }
-};
-
-/**
- * Convenient way of emitting events from the panel window.
- */
-EventEmitter.decorate(this);
-
-/**
- * DOM query helper.
- */
-function $(selector, target = document) { return target.querySelector(selector); }
-function $$(selector, target = document) { return target.querySelectorAll(selector); }
-
-/**
- * Compare `actorID` between two actors to determine if they're corresponding
- * to the same underlying actor.
- */
-function equalActors (actor1, actor2) {
-  return actor1.actorID === actor2.actorID;
-}
-
-/**
- * Returns the corresponding ViewNode by actor
- */
-function getViewNodeByActor (actor) {
-  for (let i = 0; i < AudioNodes.length; i++) {
-    if (equalActors(AudioNodes[i].actor, actor))
-      return AudioNodes[i];
-  }
-  return null;
-}
-
-/**
- * Returns the corresponding ViewNode by actorID
- */
-function getViewNodeById (id) {
-  return getViewNodeByActor({ actorID: id });
-}
-
-// Since node create and connect are probably executed back to back,
-// and the controller's `_onCreateNode` needs to look up type,
-// the edge creation could be called before the graph node is actually
-// created. This way, we can check and listen for the event before
-// adding an edge.
-function waitForNodeCreation (sourceActor, destActor) {
-  let deferred = defer();
-  let eventName = EVENTS.CREATE_NODE;
-  let source = getViewNodeByActor(sourceActor);
-  let dest = getViewNodeByActor(destActor);
-
-  if (!source || !dest)
-    window.on(eventName, function createNodeListener (_, id) {
-      let createdNode = getViewNodeById(id);
-      if (equalActors(sourceActor, createdNode.actor))
-        source = createdNode;
-      if (equalActors(destActor, createdNode.actor))
-        dest = createdNode;
-      if (source && dest) {
-        window.off(eventName, createNodeListener);
-        deferred.resolve([source, dest]);
-      }
-    });
-  else
-    deferred.resolve([source, dest]);
-  return deferred.promise;
-}
deleted file mode 100644
--- a/browser/devtools/webaudioeditor/webaudioeditor-view.js
+++ /dev/null
@@ -1,636 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this file,
- * You can obtain one at http://mozilla.org/MPL/2.0/. */
-"use strict";
-
-Cu.import("resource:///modules/devtools/VariablesView.jsm");
-Cu.import("resource:///modules/devtools/VariablesViewController.jsm");
-const { debounce } = require("sdk/lang/functional");
-
-// Strings for rendering
-const EXPAND_INSPECTOR_STRING = L10N.getStr("expandInspector");
-const COLLAPSE_INSPECTOR_STRING = L10N.getStr("collapseInspector");
-
-// Store width as a preference rather than hardcode
-// TODO bug 1009056
-const INSPECTOR_WIDTH = 300;
-
-// Globals for d3 stuff
-// Default properties of the graph on rerender
-const GRAPH_DEFAULTS = {
-  translate: [20, 20],
-  scale: 1
-};
-
-// Sizes of SVG arrows in graph
-const ARROW_HEIGHT = 5;
-const ARROW_WIDTH = 8;
-
-// Styles for markers as they cannot be done with CSS.
-const MARKER_STYLING = {
-  light: "#AAA",
-  dark: "#CED3D9"
-};
-
-const GRAPH_DEBOUNCE_TIMER = 100;
-
-const GENERIC_VARIABLES_VIEW_SETTINGS = {
-  searchEnabled: false,
-  editableValueTooltip: "",
-  editableNameTooltip: "",
-  preventDisableOnChange: true,
-  preventDescriptorModifiers: false,
-  eval: () => {}
-};
-
-/**
- * Functions handling the graph UI.
- */
-let WebAudioGraphView = {
-  /**
-   * Initialization function, called when the tool is started.
-   */
-  initialize: function() {
-    this._onGraphNodeClick = this._onGraphNodeClick.bind(this);
-    this._onThemeChange = this._onThemeChange.bind(this);
-    this._onNodeSelect = this._onNodeSelect.bind(this);
-    this._onStartContext = this._onStartContext.bind(this);
-    this._onDestroyNode = this._onDestroyNode.bind(this);
-
-    this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER);
-    $('#graph-target').addEventListener('click', this._onGraphNodeClick, false);
-
-    window.on(EVENTS.THEME_CHANGE, this._onThemeChange);
-    window.on(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect);
-    window.on(EVENTS.START_CONTEXT, this._onStartContext);
-    window.on(EVENTS.DESTROY_NODE, this._onDestroyNode);
-  },
-
-  /**
-   * Destruction function, called when the tool is closed.
-   */
-  destroy: function() {
-    if (this._zoomBinding) {
-      this._zoomBinding.on("zoom", null);
-    }
-    $('#graph-target').removeEventListener('click', this._onGraphNodeClick, false);
-    window.off(EVENTS.THEME_CHANGE, this._onThemeChange);
-    window.off(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSelect);
-    window.off(EVENTS.START_CONTEXT, this._onStartContext);
-    window.off(EVENTS.DESTROY_NODE, this._onDestroyNode);
-  },
-
-  /**
-   * Called when a page is reloaded and waiting for a "start-context" event
-   * and clears out old content
-   */
-  resetUI: function () {
-    this.clearGraph();
-    this.resetGraphPosition();
-  },
-
-  /**
-   * Clears out the rendered graph, called when resetting the SVG elements to draw again,
-   * or when resetting the entire UI tool
-   */
-  clearGraph: function () {
-    $("#graph-target").innerHTML = "";
-  },
-
-  /**
-   * Moves the graph back to its original scale and translation.
-   */
-  resetGraphPosition: function () {
-    if (this._zoomBinding) {
-      let { translate, scale } = GRAPH_DEFAULTS;
-      // Must set the `zoomBinding` so the next `zoom` event is in sync with
-      // where the graph is visually (set by the `transform` attribute).
-      this._zoomBinding.scale(scale);
-      this._zoomBinding.translate(translate);
-      d3.select("#graph-target")
-        .attr("transform", "translate(" + translate + ") scale(" + scale + ")");
-    }
-  },
-
-  getCurrentScale: function () {
-    return this._zoomBinding ? this._zoomBinding.scale() : null;
-  },
-
-  getCurrentTranslation: function () {
-    return this._zoomBinding ? this._zoomBinding.translate() : null;
-  },
-
-  /**
-   * Makes the corresponding graph node appear "focused", removing
-   * focused styles from all other nodes. If no `actorID` specified,
-   * make all nodes appear unselected.
-   * Called from UI_INSPECTOR_NODE_SELECT.
-   */
-  focusNode: function (actorID) {
-    // Remove class "selected" from all nodes
-    Array.forEach($$(".nodes > g"), $node => $node.classList.remove("selected"));
-    // Add to "selected"
-    if (actorID) {
-      this._getNodeByID(actorID).classList.add("selected");
-    }
-  },
-
-  /**
-   * Takes an actorID and returns the corresponding DOM SVG element in the graph
-   */
-  _getNodeByID: function (actorID) {
-    return $(".nodes > g[data-id='" + actorID + "']");
-  },
-
-  /**
-   * `draw` renders the ViewNodes currently available in `AudioNodes` with `AudioNodeConnections`,
-   * and `AudioParamConnections` and is throttled to be called at most every
-   * `GRAPH_DEBOUNCE_TIMER` milliseconds. Is called whenever the audio context routing changes,
-   * after being debounced.
-   */
-  draw: function () {
-    // Clear out previous SVG information
-    this.clearGraph();
-
-    let graph = new dagreD3.Digraph();
-    // An array of duples/tuples of pairs [sourceNode, destNode, param].
-    // `param` is optional, indicating a connection to an AudioParam, rather than
-    // an other AudioNode.
-    let edges = [];
-
-    AudioNodes.forEach(node => {
-      // Add node to graph
-      graph.addNode(node.id, {
-        type: node.type,                        // Just for storing type data
-        label: node.type.replace(/Node$/, ""),  // Displayed in SVG node
-        id: node.id                             // Identification
-      });
-
-      // Add all of the connections from this node to the edge array to be added
-      // after all the nodes are added, otherwise edges will attempted to be created
-      // for nodes that have not yet been added
-      AudioNodeConnections.get(node, new Set()).forEach(dest => edges.push([node, dest]));
-      let paramConnections = AudioParamConnections.get(node, {});
-      Object.keys(paramConnections).forEach(destId => {
-        let dest = getViewNodeById(destId);
-        let connections = paramConnections[destId] || [];
-        connections.forEach(param => edges.push([node, dest, param]));
-      });
-    });
-
-    edges.forEach(([node, dest, param]) => {
-      let options = {
-        source: node.id,
-        target: dest.id
-      };
-
-      // Only add `label` if `param` specified, as this is an AudioParam connection then.
-      // `label` adds the magic to render with dagre-d3, and `param` is just more explicitly
-      // the param, ignoring implementation details.
-      if (param) {
-        options.label = param;
-        options.param = param;
-      }
-
-      graph.addEdge(null, node.id, dest.id, options);
-    });
-
-    let renderer = new dagreD3.Renderer();
-
-    // Post-render manipulation of the nodes
-    let oldDrawNodes = renderer.drawNodes();
-    renderer.drawNodes(function(graph, root) {
-      let svgNodes = oldDrawNodes(graph, root);
-      svgNodes.attr("class", (n) => {
-        let node = graph.node(n);
-        return "audionode type-" + node.type;
-      });
-      svgNodes.attr("data-id", (n) => {
-        let node = graph.node(n);
-        return node.id;
-      });
-      return svgNodes;
-    });
-
-    // Post-render manipulation of edges
-    // TODO do all of this more efficiently, rather than
-    // using the direct D3 helper utilities to loop over each
-    // edge several times
-    let oldDrawEdgePaths = renderer.drawEdgePaths();
-    renderer.drawEdgePaths(function(graph, root) {
-      let svgEdges = oldDrawEdgePaths(graph, root);
-      svgEdges.attr("data-source", (n) => {
-        let edge = graph.edge(n);
-        return edge.source;
-      });
-      svgEdges.attr("data-target", (n) => {
-        let edge = graph.edge(n);
-        return edge.target;
-      });
-      svgEdges.attr("data-param", (n) => {
-        let edge = graph.edge(n);
-        return edge.param ? edge.param : null;
-      });
-      // We have to manually specify the default classes on the edges
-      // as to not overwrite them
-      let defaultClasses = "edgePath enter";
-      svgEdges.attr("class", (n) => {
-        let edge = graph.edge(n);
-        return defaultClasses + (edge.param ? (" param-connection " + edge.param) : "");
-      });
-
-      return svgEdges;
-    });
-
-    // Override Dagre-d3's post render function by passing in our own.
-    // This way we can leave styles out of it.
-    renderer.postRender((graph, root) => {
-      // We have to manually set the marker styling since we cannot
-      // do this currently with CSS, although it is in spec for SVG2
-      // https://svgwg.org/svg2-draft/painting.html#VertexMarkerProperties
-      // For now, manually set it on creation, and the `_onThemeChange`
-      // function will fire when the devtools theme changes to update the
-      // styling manually.
-      let theme = Services.prefs.getCharPref("devtools.theme");
-      let markerColor = MARKER_STYLING[theme];
-      if (graph.isDirected() && root.select("#arrowhead").empty()) {
-        root
-          .append("svg:defs")
-          .append("svg:marker")
-          .attr("id", "arrowhead")
-          .attr("viewBox", "0 0 10 10")
-          .attr("refX", ARROW_WIDTH)
-          .attr("refY", ARROW_HEIGHT)
-          .attr("markerUnits", "strokewidth")
-          .attr("markerWidth", ARROW_WIDTH)
-          .attr("markerHeight", ARROW_HEIGHT)
-          .attr("orient", "auto")
-          .attr("style", "fill: " + markerColor)
-          .append("svg:path")
-          .attr("d", "M 0 0 L 10 5 L 0 10 z");
-      }
-
-      // Reselect the previously selected audio node
-      let currentNode = WebAudioInspectorView.getCurrentAudioNode();
-      if (currentNode) {
-        this.focusNode(currentNode.id);
-      }
-
-      // Fire an event upon completed rendering
-      let paramEdgeCount = edges.filter(p => !!p[2]).length;
-      window.emit(EVENTS.UI_GRAPH_RENDERED, AudioNodes.length, edges.length - paramEdgeCount, paramEdgeCount);
-    });
-
-    let layout = dagreD3.layout().rankDir("LR");
-    renderer.layout(layout).run(graph, d3.select("#graph-target"));
-
-    // Handle the sliding and zooming of the graph,
-    // store as `this._zoomBinding` so we can unbind during destruction
-    if (!this._zoomBinding) {
-      this._zoomBinding = d3.behavior.zoom().on("zoom", function () {
-        var ev = d3.event;
-        d3.select("#graph-target")
-          .attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")");
-      });
-      d3.select("svg").call(this._zoomBinding);
-
-      // Set initial translation and scale -- this puts D3's awareness of
-      // the graph in sync with what the user sees originally.
-      this.resetGraphPosition();
-    }
-  },
-
-  /**
-   * Event handlers
-   */
-
-  /**
-   * Called once "start-context" is fired, indicating that there is an audio
-   * context being created to view so render the graph.
-   */
-  _onStartContext: function () {
-    this.draw();
-  },
-
-  /**
-   * Called when a node gets GC'd -- redraws the graph.
-   */
-  _onDestroyNode: function () {
-    this.draw();
-  },
-
-  _onNodeSelect: function (eventName, id) {
-    this.focusNode(id);
-  },
-
-  /**
-   * Fired when the devtools theme changes.
-   */
-  _onThemeChange: function (eventName, theme) {
-    let markerColor = MARKER_STYLING[theme];
-    let marker = $("#arrowhead");
-    if (marker) {
-      marker.setAttribute("style", "fill: " + markerColor);
-    }
-  },
-
-  /**
-   * Fired when a node in the svg graph is clicked. Used to handle triggering the AudioNodePane.
-   *
-   * @param Event e
-   *        Click event.
-   */
-  _onGraphNodeClick: function (e) {
-    let node = findGraphNodeParent(e.target);
-    // If node not found (clicking outside of an audio node in the graph),
-    // then ignore this event
-    if (!node)
-      return;
-
-    window.emit(EVENTS.UI_SELECT_NODE, node.getAttribute("data-id"));
-  }
-};
-
-let WebAudioInspectorView = {
-
-  _propsView: null,
-
-  _currentNode: null,
-
-  _inspectorPane: null,
-  _inspectorPaneToggleButton: null,
-  _tabsPane: null,
-
-  /**
-   * Initialization function called when the tool starts up.
-   */
-  initialize: function () {
-    this._inspectorPane = $("#web-audio-inspector");
-    this._inspectorPaneToggleButton = $("#inspector-pane-toggle");
-    this._tabsPane = $("#web-audio-editor-tabs");
-
-    // Hide inspector view on startup
-    this._inspectorPane.setAttribute("width", INSPECTOR_WIDTH);
-    this.toggleInspector({ visible: false, delayed: false, animated: false });
-
-    this._onEval = this._onEval.bind(this);
-    this._onNodeSelect = this._onNodeSelect.bind(this);
-    this._onTogglePaneClick = this._onTogglePaneClick.bind(this);
-    this._onDestroyNode = this._onDestroyNode.bind(this);
-
-    this._inspectorPaneToggleButton.addEventListener("mousedown", this._onTogglePaneClick, false);
-    this._propsView = new VariablesView($("#properties-tabpanel-content"), GENERIC_VARIABLES_VIEW_SETTINGS);
-    this._propsView.eval = this._onEval;
-
-    window.on(EVENTS.UI_SELECT_NODE, this._onNodeSelect);
-    window.on(EVENTS.DESTROY_NODE, this._onDestroyNode);
-  },
-
-  /**
-   * Destruction function called when the tool cleans up.
-   */
-  destroy: function () {
-    this._inspectorPaneToggleButton.removeEventListener("mousedown", this._onTogglePaneClick);
-    window.off(EVENTS.UI_SELECT_NODE, this._onNodeSelect);
-    window.off(EVENTS.DESTROY_NODE, this._onDestroyNode);
-
-    this._inspectorPane = null;
-    this._inspectorPaneToggleButton = null;
-    this._tabsPane = null;
-  },
-
-  /**
-   * Toggles the visibility of the AudioNode Inspector.
-   *
-   * @param object visible
-   *        - visible: boolean indicating whether the panel should be shown or not
-   *        - animated: boolean indiciating whether the pane should be animated
-   *        - delayed: boolean indicating whether the pane's opening should wait
-   *                   a few cycles or not
-   *        - index: the index of the tab to be selected inside the inspector
-   * @param number index
-   *        Index of the tab that should be selected when shown.
-   */
-  toggleInspector: function ({ visible, animated, delayed, index }) {
-    let pane = this._inspectorPane;
-    let button = this._inspectorPaneToggleButton;
-
-    let flags = {
-      visible: visible,
-      animated: animated != null ? animated : true,
-      delayed: delayed != null ? delayed : true,
-      callback: () => window.emit(EVENTS.UI_INSPECTOR_TOGGLED, visible)
-    };
-
-    ViewHelpers.togglePane(flags, pane);
-
-    if (flags.visible) {
-      button.removeAttribute("pane-collapsed");
-      button.setAttribute("tooltiptext", COLLAPSE_INSPECTOR_STRING);
-    }
-    else {
-      button.setAttribute("pane-collapsed", "");
-      button.setAttribute("tooltiptext", EXPAND_INSPECTOR_STRING);
-    }
-
-    if (index != undefined) {
-      pane.selectedIndex = index;
-    }
-  },
-
-  /**
-   * Returns a boolean indicating whether or not the AudioNode inspector
-   * is currently being shown.
-   */
-  isVisible: function () {
-    return !this._inspectorPane.hasAttribute("pane-collapsed");
-  },
-
-  /**
-   * Takes a AudioNodeView `node` and sets it as the current
-   * node and scaffolds the inspector view based off of the new node.
-   */
-  setCurrentAudioNode: function (node) {
-    this._currentNode = node || null;
-
-    // If no node selected, set the inspector back to "no AudioNode selected"
-    // view.
-    if (!node) {
-      $("#web-audio-editor-details-pane-empty").removeAttribute("hidden");
-      $("#web-audio-editor-tabs").setAttribute("hidden", "true");
-      window.emit(EVENTS.UI_INSPECTOR_NODE_SET, null);
-    }
-    // Otherwise load up the tabs view and hide the empty placeholder
-    else {
-      $("#web-audio-editor-details-pane-empty").setAttribute("hidden", "true");
-      $("#web-audio-editor-tabs").removeAttribute("hidden");
-      this._setTitle();
-      this._buildPropertiesView()
-        .then(() => window.emit(EVENTS.UI_INSPECTOR_NODE_SET, this._currentNode.id));
-    }
-  },
-
-  /**
-   * Returns the current AudioNodeView.
-   */
-  getCurrentAudioNode: function () {
-    return this._currentNode;
-  },
-
-  /**
-   * Empties out the props view.
-   */
-  resetUI: function () {
-    this._propsView.empty();
-    // Set current node to empty to load empty view
-    this.setCurrentAudioNode();
-
-    // Reset AudioNode inspector and hide
-    this.toggleInspector({ visible: false, animated: false, delayed: false });
-  },
-
-  /**
-   * Sets the title of the Inspector view
-   */
-  _setTitle: function () {
-    let node = this._currentNode;
-    let title = node.type.replace(/Node$/, "");
-    $("#web-audio-inspector-title").setAttribute("value", title);
-  },
-
-  /**
-   * Reconstructs the `Properties` tab in the inspector
-   * with the `this._currentNode` as it's source.
-   */
-  _buildPropertiesView: Task.async(function* () {
-    let propsView = this._propsView;
-    let node = this._currentNode;
-    propsView.empty();
-
-    let audioParamsScope = propsView.addScope("AudioParams");
-    let props = yield node.getParams();
-
-    // Disable AudioParams VariableView expansion
-    // when there are no props i.e. AudioDestinationNode
-    this._togglePropertiesView(!!props.length);
-
-    props.forEach(({ param, value, flags }) => {
-      let descriptor = {
-        value: value,
-        writable: !flags || !flags.readonly,
-      };
-      audioParamsScope.addItem(param, descriptor);
-    });
-
-    audioParamsScope.expanded = true;
-
-    window.emit(EVENTS.UI_PROPERTIES_TAB_RENDERED, node.id);
-  }),
-
-  _togglePropertiesView: function (show) {
-    let propsView = $("#properties-tabpanel-content");
-    let emptyView = $("#properties-tabpanel-content-empty");
-    (show ? propsView : emptyView).removeAttribute("hidden");
-    (show ? emptyView : propsView).setAttribute("hidden", "true");
-  },
-
-  /**
-   * Returns the scope for AudioParams in the
-   * VariablesView.
-   *
-   * @return Scope
-   */
-  _getAudioPropertiesScope: function () {
-    return this._propsView.getScopeAtIndex(0);
-  },
-
-  /**
-   * Event handlers
-   */
-
-  /**
-   * Executed when an audio prop is changed in the UI.
-   */
-  _onEval: Task.async(function* (variable, value) {
-    let ownerScope = variable.ownerView;
-    let node = this._currentNode;
-    let propName = variable.name;
-    let error;
-
-    if (!variable._initialDescriptor.writable) {
-      error = new Error("Variable " + propName + " is not writable.");
-    } else {
-      // Cast value to proper type
-      try {
-        let number = parseFloat(value);
-        if (!isNaN(number)) {
-          value = number;
-        } else {
-          value = JSON.parse(value);
-        }
-        error = yield node.actor.setParam(propName, value);
-      }
-      catch (e) {
-        error = e;
-      }
-    }
-
-    // TODO figure out how to handle and display set prop errors
-    // and enable `test/brorwser_wa_properties-view-edit.js`
-    // Bug 994258
-    if (!error) {
-      ownerScope.get(propName).setGrip(value);
-      window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value);
-    } else {
-      window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value);
-    }
-  }),
-
-  /**
-   * Called on EVENTS.UI_SELECT_NODE, and takes an actorID `id`
-   * and calls `setCurrentAudioNode` to scaffold the inspector view.
-   */
-  _onNodeSelect: function (_, id) {
-    this.setCurrentAudioNode(getViewNodeById(id));
-
-    // Ensure inspector is visible when selecting a new node
-    this.toggleInspector({ visible: true });
-  },
-
-  /**
-   * Called when clicking on the toggling the inspector into view.
-   */
-  _onTogglePaneClick: function () {
-    this.toggleInspector({ visible: !this.isVisible() });
-  },
-
-  /**
-   * Called when `DESTROY_NODE` is fired to remove the node from props view if
-   * it's currently selected.
-   */
-  _onDestroyNode: function (_, id) {
-    if (this._currentNode && this._currentNode.id === id) {
-      this.setCurrentAudioNode(null);
-    }
-  }
-};
-
-/**
- * Takes an element in an SVG graph and iterates over
- * ancestors until it finds the graph node container. If not found,
- * returns null.
- */
-
-function findGraphNodeParent (el) {
-  // Some targets may not contain `classList` property
-  if (!el.classList)
-    return null;
-
-  while (!el.classList.contains("nodes")) {
-    if (el.classList.contains("audionode"))
-      return el;
-    else
-      el = el.parentNode;
-  }
-  return null;
-}
--- a/browser/devtools/webaudioeditor/webaudioeditor.xul
+++ b/browser/devtools/webaudioeditor/webaudioeditor.xul
@@ -14,18 +14,22 @@
 
 <window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
 
   <script type="application/javascript;version=1.8"
           src="chrome://browser/content/devtools/theme-switching.js"/>
 
   <script type="application/javascript" src="chrome://browser/content/devtools/d3.js"/>
   <script type="application/javascript" src="dagre-d3.js"/>
-  <script type="application/javascript" src="webaudioeditor-controller.js"/>
-  <script type="application/javascript" src="webaudioeditor-view.js"/>
+  <script type="application/javascript" src="webaudioeditor/includes.js"/>
+  <script type="application/javascript" src="webaudioeditor/models.js"/>
+  <script type="application/javascript" src="webaudioeditor/controller.js"/>
+  <script type="application/javascript" src="webaudioeditor/views/utils.js"/>
+  <script type="application/javascript" src="webaudioeditor/views/context.js"/>
+  <script type="application/javascript" src="webaudioeditor/views/inspector.js"/>
 
   <vbox class="theme-body" flex="1">
     <hbox id="reload-notice"
           class="notice-container"
           align="center"
           pack="center"
           flex="1">
       <button id="requests-menu-reload-notice-button"