|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 "use strict"; |
|
5 |
|
6 const {Cc, Ci, Cu, Cr} = require("chrome"); |
|
7 |
|
8 const Services = require("Services"); |
|
9 |
|
10 const { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {}); |
|
11 const events = require("sdk/event/core"); |
|
12 const protocol = require("devtools/server/protocol"); |
|
13 const { CallWatcherActor, CallWatcherFront } = require("devtools/server/actors/call-watcher"); |
|
14 |
|
15 const { on, once, off, emit } = events; |
|
16 const { method, Arg, Option, RetVal } = protocol; |
|
17 |
|
18 exports.register = function(handle) { |
|
19 handle.addTabActor(WebAudioActor, "webaudioActor"); |
|
20 }; |
|
21 |
|
22 exports.unregister = function(handle) { |
|
23 handle.removeTabActor(WebAudioActor); |
|
24 }; |
|
25 |
|
26 const AUDIO_GLOBALS = [ |
|
27 "AudioContext", "AudioNode" |
|
28 ]; |
|
29 |
|
30 const NODE_CREATION_METHODS = [ |
|
31 "createBufferSource", "createMediaElementSource", "createMediaStreamSource", |
|
32 "createMediaStreamDestination", "createScriptProcessor", "createAnalyser", |
|
33 "createGain", "createDelay", "createBiquadFilter", "createWaveShaper", |
|
34 "createPanner", "createConvolver", "createChannelSplitter", "createChannelMerger", |
|
35 "createDynamicsCompressor", "createOscillator" |
|
36 ]; |
|
37 |
|
38 const NODE_ROUTING_METHODS = [ |
|
39 "connect", "disconnect" |
|
40 ]; |
|
41 |
|
42 const NODE_PROPERTIES = { |
|
43 "OscillatorNode": { |
|
44 "type": {}, |
|
45 "frequency": {}, |
|
46 "detune": {} |
|
47 }, |
|
48 "GainNode": { |
|
49 "gain": {} |
|
50 }, |
|
51 "DelayNode": { |
|
52 "delayTime": {} |
|
53 }, |
|
54 "AudioBufferSourceNode": { |
|
55 "buffer": { "Buffer": true }, |
|
56 "playbackRate": {}, |
|
57 "loop": {}, |
|
58 "loopStart": {}, |
|
59 "loopEnd": {} |
|
60 }, |
|
61 "ScriptProcessorNode": { |
|
62 "bufferSize": { "readonly": true } |
|
63 }, |
|
64 "PannerNode": { |
|
65 "panningModel": {}, |
|
66 "distanceModel": {}, |
|
67 "refDistance": {}, |
|
68 "maxDistance": {}, |
|
69 "rolloffFactor": {}, |
|
70 "coneInnerAngle": {}, |
|
71 "coneOuterAngle": {}, |
|
72 "coneOuterGain": {} |
|
73 }, |
|
74 "ConvolverNode": { |
|
75 "buffer": { "Buffer": true }, |
|
76 "normalize": {}, |
|
77 }, |
|
78 "DynamicsCompressorNode": { |
|
79 "threshold": {}, |
|
80 "knee": {}, |
|
81 "ratio": {}, |
|
82 "reduction": {}, |
|
83 "attack": {}, |
|
84 "release": {} |
|
85 }, |
|
86 "BiquadFilterNode": { |
|
87 "type": {}, |
|
88 "frequency": {}, |
|
89 "Q": {}, |
|
90 "detune": {}, |
|
91 "gain": {} |
|
92 }, |
|
93 "WaveShaperNode": { |
|
94 "curve": { "Float32Array": true }, |
|
95 "oversample": {} |
|
96 }, |
|
97 "AnalyserNode": { |
|
98 "fftSize": {}, |
|
99 "minDecibels": {}, |
|
100 "maxDecibels": {}, |
|
101 "smoothingTimeConstraint": {}, |
|
102 "frequencyBinCount": { "readonly": true }, |
|
103 }, |
|
104 "AudioDestinationNode": {}, |
|
105 "ChannelSplitterNode": {}, |
|
106 "ChannelMergerNode": {} |
|
107 }; |
|
108 |
|
109 /** |
|
110 * Track an array of audio nodes |
|
111 |
|
112 /** |
|
113 * An Audio Node actor allowing communication to a specific audio node in the |
|
114 * Audio Context graph. |
|
115 */ |
|
116 let AudioNodeActor = exports.AudioNodeActor = protocol.ActorClass({ |
|
117 typeName: "audionode", |
|
118 |
|
119 /** |
|
120 * Create the Audio Node actor. |
|
121 * |
|
122 * @param DebuggerServerConnection conn |
|
123 * The server connection. |
|
124 * @param AudioNode node |
|
125 * The AudioNode that was created. |
|
126 */ |
|
127 initialize: function (conn, node) { |
|
128 protocol.Actor.prototype.initialize.call(this, conn); |
|
129 this.node = unwrap(node); |
|
130 try { |
|
131 this.type = this.node.toString().match(/\[object (.*)\]$/)[1]; |
|
132 } catch (e) { |
|
133 this.type = ""; |
|
134 } |
|
135 }, |
|
136 |
|
137 /** |
|
138 * Returns the name of the audio type. |
|
139 * Examples: "OscillatorNode", "MediaElementAudioSourceNode" |
|
140 */ |
|
141 getType: method(function () { |
|
142 return this.type; |
|
143 }, { |
|
144 response: { type: RetVal("string") } |
|
145 }), |
|
146 |
|
147 /** |
|
148 * Returns a boolean indicating if the node is a source node, |
|
149 * like BufferSourceNode, MediaElementAudioSourceNode, OscillatorNode, etc. |
|
150 */ |
|
151 isSource: method(function () { |
|
152 return !!~this.type.indexOf("Source") || this.type === "OscillatorNode"; |
|
153 }, { |
|
154 response: { source: RetVal("boolean") } |
|
155 }), |
|
156 |
|
157 /** |
|
158 * Changes a param on the audio node. Responds with a `string` that's either |
|
159 * an empty string `""` on success, or a description of the error upon |
|
160 * param set failure. |
|
161 * |
|
162 * @param String param |
|
163 * Name of the AudioParam to change. |
|
164 * @param String value |
|
165 * Value to change AudioParam to. |
|
166 */ |
|
167 setParam: method(function (param, value) { |
|
168 // Strip quotes because sometimes UIs include that for strings |
|
169 if (typeof value === "string") { |
|
170 value = value.replace(/[\'\"]*/g, ""); |
|
171 } |
|
172 try { |
|
173 if (isAudioParam(this.node, param)) |
|
174 this.node[param].value = value; |
|
175 else |
|
176 this.node[param] = value; |
|
177 return undefined; |
|
178 } catch (e) { |
|
179 return constructError(e); |
|
180 } |
|
181 }, { |
|
182 request: { |
|
183 param: Arg(0, "string"), |
|
184 value: Arg(1, "nullable:primitive") |
|
185 }, |
|
186 response: { error: RetVal("nullable:json") } |
|
187 }), |
|
188 |
|
189 /** |
|
190 * Gets a param on the audio node. |
|
191 * |
|
192 * @param String param |
|
193 * Name of the AudioParam to fetch. |
|
194 */ |
|
195 getParam: method(function (param) { |
|
196 // If property does not exist, just return "undefined" |
|
197 if (!this.node[param]) |
|
198 return undefined; |
|
199 let value = isAudioParam(this.node, param) ? this.node[param].value : this.node[param]; |
|
200 return value; |
|
201 }, { |
|
202 request: { |
|
203 param: Arg(0, "string") |
|
204 }, |
|
205 response: { text: RetVal("nullable:primitive") } |
|
206 }), |
|
207 |
|
208 /** |
|
209 * Get an object containing key-value pairs of additional attributes |
|
210 * to be consumed by a front end, like if a property should be read only, |
|
211 * or is a special type (Float32Array, Buffer, etc.) |
|
212 * |
|
213 * @param String param |
|
214 * Name of the AudioParam whose flags are desired. |
|
215 */ |
|
216 getParamFlags: method(function (param) { |
|
217 return (NODE_PROPERTIES[this.type] || {})[param]; |
|
218 }, { |
|
219 request: { param: Arg(0, "string") }, |
|
220 response: { flags: RetVal("nullable:primitive") } |
|
221 }), |
|
222 |
|
223 /** |
|
224 * Get an array of objects each containing a `param` and `value` property, |
|
225 * corresponding to a property name and current value of the audio node. |
|
226 */ |
|
227 getParams: method(function (param) { |
|
228 let props = Object.keys(NODE_PROPERTIES[this.type]); |
|
229 return props.map(prop => |
|
230 ({ param: prop, value: this.getParam(prop), flags: this.getParamFlags(prop) })); |
|
231 }, { |
|
232 response: { params: RetVal("json") } |
|
233 }) |
|
234 }); |
|
235 |
|
236 /** |
|
237 * The corresponding Front object for the AudioNodeActor. |
|
238 */ |
|
239 let AudioNodeFront = protocol.FrontClass(AudioNodeActor, { |
|
240 initialize: function (client, form) { |
|
241 protocol.Front.prototype.initialize.call(this, client, form); |
|
242 client.addActorPool(this); |
|
243 this.manage(this); |
|
244 } |
|
245 }); |
|
246 |
|
247 /** |
|
248 * The Web Audio Actor handles simple interaction with an AudioContext |
|
249 * high-level methods. After instantiating this actor, you'll need to set it |
|
250 * up by calling setup(). |
|
251 */ |
|
252 let WebAudioActor = exports.WebAudioActor = protocol.ActorClass({ |
|
253 typeName: "webaudio", |
|
254 initialize: function(conn, tabActor) { |
|
255 protocol.Actor.prototype.initialize.call(this, conn); |
|
256 this.tabActor = tabActor; |
|
257 this._onContentFunctionCall = this._onContentFunctionCall.bind(this); |
|
258 }, |
|
259 |
|
260 destroy: function(conn) { |
|
261 protocol.Actor.prototype.destroy.call(this, conn); |
|
262 this.finalize(); |
|
263 }, |
|
264 |
|
265 /** |
|
266 * Starts waiting for the current tab actor's document global to be |
|
267 * created, in order to instrument the Canvas context and become |
|
268 * aware of everything the content does with Web Audio. |
|
269 * |
|
270 * See ContentObserver and WebAudioInstrumenter for more details. |
|
271 */ |
|
272 setup: method(function({ reload }) { |
|
273 if (this._initialized) { |
|
274 return; |
|
275 } |
|
276 this._initialized = true; |
|
277 |
|
278 // Weak map mapping audio nodes to their corresponding actors |
|
279 this._nodeActors = new Map(); |
|
280 |
|
281 this._callWatcher = new CallWatcherActor(this.conn, this.tabActor); |
|
282 this._callWatcher.onCall = this._onContentFunctionCall; |
|
283 this._callWatcher.setup({ |
|
284 tracedGlobals: AUDIO_GLOBALS, |
|
285 startRecording: true, |
|
286 performReload: reload |
|
287 }); |
|
288 |
|
289 // Used to track when something is happening with the web audio API |
|
290 // the first time, to ultimately fire `start-context` event |
|
291 this._firstNodeCreated = false; |
|
292 }, { |
|
293 request: { reload: Option(0, "boolean") }, |
|
294 oneway: true |
|
295 }), |
|
296 |
|
297 /** |
|
298 * Invoked whenever an instrumented function is called, like an AudioContext |
|
299 * method or an AudioNode method. |
|
300 */ |
|
301 _onContentFunctionCall: function(functionCall) { |
|
302 let { name } = functionCall.details; |
|
303 |
|
304 // All Web Audio nodes inherit from AudioNode's prototype, so |
|
305 // hook into the `connect` and `disconnect` methods |
|
306 if (WebAudioFront.NODE_ROUTING_METHODS.has(name)) { |
|
307 this._handleRoutingCall(functionCall); |
|
308 } |
|
309 else if (WebAudioFront.NODE_CREATION_METHODS.has(name)) { |
|
310 this._handleCreationCall(functionCall); |
|
311 } |
|
312 }, |
|
313 |
|
314 _handleRoutingCall: function(functionCall) { |
|
315 let { caller, args, window, name } = functionCall.details; |
|
316 let source = unwrap(caller); |
|
317 let dest = unwrap(args[0]); |
|
318 let isAudioParam = dest instanceof unwrap(window.AudioParam); |
|
319 |
|
320 // audionode.connect(param) |
|
321 if (name === "connect" && isAudioParam) { |
|
322 this._onConnectParam(source, dest); |
|
323 } |
|
324 // audionode.connect(node) |
|
325 else if (name === "connect") { |
|
326 this._onConnectNode(source, dest); |
|
327 } |
|
328 // audionode.disconnect() |
|
329 else if (name === "disconnect") { |
|
330 this._onDisconnectNode(source); |
|
331 } |
|
332 }, |
|
333 |
|
334 _handleCreationCall: function (functionCall) { |
|
335 let { caller, result } = functionCall.details; |
|
336 // Keep track of the first node created, so we can alert |
|
337 // the front end that an audio context is being used since |
|
338 // we're not hooking into the constructor itself, just its |
|
339 // instance's methods. |
|
340 if (!this._firstNodeCreated) { |
|
341 // Fire the start-up event if this is the first node created |
|
342 // and trigger a `create-node` event for the context destination |
|
343 this._onStartContext(); |
|
344 this._onCreateNode(unwrap(caller.destination)); |
|
345 this._firstNodeCreated = true; |
|
346 } |
|
347 this._onCreateNode(result); |
|
348 }, |
|
349 |
|
350 /** |
|
351 * Stops listening for document global changes and puts this actor |
|
352 * to hibernation. This method is called automatically just before the |
|
353 * actor is destroyed. |
|
354 */ |
|
355 finalize: method(function() { |
|
356 if (!this._initialized) { |
|
357 return; |
|
358 } |
|
359 this._initialized = false; |
|
360 this._callWatcher.eraseRecording(); |
|
361 |
|
362 this._callWatcher.finalize(); |
|
363 this._callWatcher = null; |
|
364 }, { |
|
365 oneway: true |
|
366 }), |
|
367 |
|
368 /** |
|
369 * Events emitted by this actor. |
|
370 */ |
|
371 events: { |
|
372 "start-context": { |
|
373 type: "startContext" |
|
374 }, |
|
375 "connect-node": { |
|
376 type: "connectNode", |
|
377 source: Option(0, "audionode"), |
|
378 dest: Option(0, "audionode") |
|
379 }, |
|
380 "disconnect-node": { |
|
381 type: "disconnectNode", |
|
382 source: Arg(0, "audionode") |
|
383 }, |
|
384 "connect-param": { |
|
385 type: "connectParam", |
|
386 source: Arg(0, "audionode"), |
|
387 param: Arg(1, "string") |
|
388 }, |
|
389 "change-param": { |
|
390 type: "changeParam", |
|
391 source: Option(0, "audionode"), |
|
392 param: Option(0, "string"), |
|
393 value: Option(0, "string") |
|
394 }, |
|
395 "create-node": { |
|
396 type: "createNode", |
|
397 source: Arg(0, "audionode") |
|
398 } |
|
399 }, |
|
400 |
|
401 /** |
|
402 * Helper for constructing an AudioNodeActor, assigning to |
|
403 * internal weak map, and tracking via `manage` so it is assigned |
|
404 * an `actorID`. |
|
405 */ |
|
406 _constructAudioNode: function (node) { |
|
407 let actor = new AudioNodeActor(this.conn, node); |
|
408 this.manage(actor); |
|
409 this._nodeActors.set(node, actor); |
|
410 return actor; |
|
411 }, |
|
412 |
|
413 /** |
|
414 * Takes an AudioNode and returns the stored actor for it. |
|
415 * In some cases, we won't have an actor stored (for example, |
|
416 * connecting to an AudioDestinationNode, since it's implicitly |
|
417 * created), so make a new actor and store that. |
|
418 */ |
|
419 _actorFor: function (node) { |
|
420 let actor = this._nodeActors.get(node); |
|
421 if (!actor) { |
|
422 actor = this._constructAudioNode(node); |
|
423 } |
|
424 return actor; |
|
425 }, |
|
426 |
|
427 /** |
|
428 * Called on first audio node creation, signifying audio context usage |
|
429 */ |
|
430 _onStartContext: function () { |
|
431 events.emit(this, "start-context"); |
|
432 }, |
|
433 |
|
434 /** |
|
435 * Called when one audio node is connected to another. |
|
436 */ |
|
437 _onConnectNode: function (source, dest) { |
|
438 let sourceActor = this._actorFor(source); |
|
439 let destActor = this._actorFor(dest); |
|
440 events.emit(this, "connect-node", { |
|
441 source: sourceActor, |
|
442 dest: destActor |
|
443 }); |
|
444 }, |
|
445 |
|
446 /** |
|
447 * Called when an audio node is connected to an audio param. |
|
448 * Implement in bug 986705 |
|
449 */ |
|
450 _onConnectParam: function (source, dest) { |
|
451 // TODO bug 986705 |
|
452 }, |
|
453 |
|
454 /** |
|
455 * Called when an audio node is disconnected. |
|
456 */ |
|
457 _onDisconnectNode: function (node) { |
|
458 let actor = this._actorFor(node); |
|
459 events.emit(this, "disconnect-node", actor); |
|
460 }, |
|
461 |
|
462 /** |
|
463 * Called when a parameter changes on an audio node |
|
464 */ |
|
465 _onParamChange: function (node, param, value) { |
|
466 let actor = this._actorFor(node); |
|
467 events.emit(this, "param-change", { |
|
468 source: actor, |
|
469 param: param, |
|
470 value: value |
|
471 }); |
|
472 }, |
|
473 |
|
474 /** |
|
475 * Called on node creation. |
|
476 */ |
|
477 _onCreateNode: function (node) { |
|
478 let actor = this._constructAudioNode(node); |
|
479 events.emit(this, "create-node", actor); |
|
480 } |
|
481 }); |
|
482 |
|
483 /** |
|
484 * The corresponding Front object for the WebAudioActor. |
|
485 */ |
|
486 let WebAudioFront = exports.WebAudioFront = protocol.FrontClass(WebAudioActor, { |
|
487 initialize: function(client, { webaudioActor }) { |
|
488 protocol.Front.prototype.initialize.call(this, client, { actor: webaudioActor }); |
|
489 client.addActorPool(this); |
|
490 this.manage(this); |
|
491 } |
|
492 }); |
|
493 |
|
494 WebAudioFront.NODE_CREATION_METHODS = new Set(NODE_CREATION_METHODS); |
|
495 WebAudioFront.NODE_ROUTING_METHODS = new Set(NODE_ROUTING_METHODS); |
|
496 |
|
497 /** |
|
498 * Determines whether or not property is an AudioParam. |
|
499 * |
|
500 * @param AudioNode node |
|
501 * An AudioNode. |
|
502 * @param String prop |
|
503 * Property of `node` to evaluate to see if it's an AudioParam. |
|
504 * @return Boolean |
|
505 */ |
|
506 function isAudioParam (node, prop) { |
|
507 return /AudioParam/.test(node[prop].toString()); |
|
508 } |
|
509 |
|
510 /** |
|
511 * Takes an `Error` object and constructs a JSON-able response |
|
512 * |
|
513 * @param Error err |
|
514 * A TypeError, RangeError, etc. |
|
515 * @return Object |
|
516 */ |
|
517 function constructError (err) { |
|
518 return { |
|
519 message: err.message, |
|
520 type: err.constructor.name |
|
521 }; |
|
522 } |
|
523 |
|
524 function unwrap (obj) { |
|
525 return XPCNativeWrapper.unwrap(obj); |
|
526 } |