| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import '../../ui/legacy/legacy.js'; |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as i18n from '../../core/i18n/i18n.js'; |
| import type * as Platform from '../../core/platform/platform.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import {html, render} from '../../ui/lit/lit.js'; |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; |
| |
| import webAudioStyles from './webAudio.css.js'; |
| import {Events as ModelEvents, WebAudioModel} from './WebAudioModel.js'; |
| const {widgetConfig} = UI.Widget; |
| const {bindToAction} = UI.UIUtils; |
| |
| const UIStrings = { |
| /** |
| * @description Text in Web Audio View if there is nothing to show. |
| * Web Audio API is an API for controlling audio on the web. |
| */ |
| noWebAudio: 'No Web Audio API usage detected', |
| /** |
| * @description Text in Web Audio View |
| */ |
| openAPageThatUsesWebAudioApiTo: 'Open a page that uses Web Audio API to start monitoring.', |
| /** |
| * @description Text that shows there is no recording |
| */ |
| noRecordings: '(no recordings)', |
| /** |
| * @description Label prefix for an audio context selection |
| * @example {realtime (1e03ec)} PH1 |
| */ |
| audioContextS: 'Audio context: {PH1}', |
| /** |
| * @description The current state of an item |
| */ |
| state: 'State', |
| /** |
| * @description Text in Web Audio View |
| */ |
| sampleRate: 'Sample Rate', |
| /** |
| * @description Text in Web Audio View |
| */ |
| callbackBufferSize: 'Callback Buffer Size', |
| /** |
| * @description Label in the Web Audio View for the maximum number of output channels |
| * that this Audio Context has. |
| */ |
| maxOutputChannels: 'Max Output Channels', |
| /** |
| * @description Text in Web Audio View |
| */ |
| currentTime: 'Current Time', |
| /** |
| * @description Text in Web Audio View |
| */ |
| callbackInterval: 'Callback Interval', |
| /** |
| * @description Text in Web Audio View |
| */ |
| renderCapacity: 'Render Capacity', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('panels/web_audio/WebAudioView.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| const WEBAUDIO_EXPLANATION_URL = |
| 'https://developer.chrome.com/docs/devtools/webaudio' as Platform.DevToolsPath.UrlString; |
| |
| interface ViewInput { |
| contexts: Protocol.WebAudio.BaseAudioContext[]; |
| selectedContextIndex: number; |
| onContextSelectorSelectionChanged: (contextId: string) => void; |
| contextRealtimeData: Protocol.WebAudio.ContextRealtimeData|null; |
| } |
| |
| type View = (input: ViewInput, output: object, target: HTMLElement) => void; |
| |
| export const DEFAULT_VIEW: View = (input, _output, target) => { |
| const { |
| contexts, |
| selectedContextIndex, |
| onContextSelectorSelectionChanged, |
| contextRealtimeData, |
| } = input; |
| const selectedContext = selectedContextIndex > -1 ? contexts[selectedContextIndex] : null; |
| |
| const titleForContext = (context: Protocol.WebAudio.BaseAudioContext): string => |
| context.contextType + ' (' + context.contextId.substr(-6) + ')'; |
| |
| const selectorTitle = i18nString( |
| UIStrings.audioContextS, |
| {PH1: selectedContext ? titleForContext(selectedContext) : i18nString(UIStrings.noRecordings)}); |
| |
| // clang-format off |
| render(html` |
| <style>${webAudioStyles}</style> |
| <div class="web-audio-toolbar-container vbox" role="toolbar"> |
| <devtools-toolbar class="web-audio-toolbar" role="presentation" |
| jslog=${VisualLogging.toolbar()}> |
| <devtools-button ${bindToAction('components.collect-garbage')}></devtools-button> |
| <div class="toolbar-divider"></div> |
| <select |
| title=${selectorTitle} |
| aria-label=${selectorTitle} |
| ?disabled=${contexts.length === 0} |
| @change=${(e: Event) => onContextSelectorSelectionChanged((e.target as HTMLSelectElement).value)} |
| .value=${selectedContext ? selectedContext.contextId : ''}> |
| ${contexts.length === 0 |
| ? html`<option value="" hidden>${i18nString(UIStrings.noRecordings)}</option>` |
| : contexts.map(context => html` |
| <option value=${context.contextId}>${titleForContext(context)}</option> |
| `)} |
| </select> |
| </devtools-toolbar> |
| </div> |
| <div class="web-audio-content-container vbox flex-auto"> |
| ${!selectedContext ? html` |
| <div class="web-audio-details-container vbox flex-auto"> |
| <devtools-widget .widgetConfig=${widgetConfig(UI.EmptyWidget.EmptyWidget, |
| {header: i18nString(UIStrings.noWebAudio), |
| text: i18nString(UIStrings.openAPageThatUsesWebAudioApiTo), |
| link: WEBAUDIO_EXPLANATION_URL, |
| })}> |
| </devtools-widget> |
| </div>` : html`<div class="web-audio-details-container vbox flex-auto"> |
| <div class="context-detail-container"> |
| <div class="context-detail-header"> |
| <div class="context-detail-title"> |
| ${selectedContext.contextType === 'realtime' ? i18n.i18n.lockedString('AudioContext') |
| : i18n.i18n.lockedString('OfflineAudioContext')} |
| </div> |
| <div class="context-detail-subtitle">${selectedContext.contextId}</div> |
| </div> |
| <div class="context-detail-row"> |
| <div class="context-detail-row-entry">${i18nString(UIStrings.state)}</div> |
| <div class="context-detail-row-value">${selectedContext.contextState}</div> |
| </div> |
| <div class="context-detail-row"> |
| <div class="context-detail-row-entry">${i18nString(UIStrings.sampleRate)}</div> |
| <div class="context-detail-row-value">${selectedContext.sampleRate} Hz</div> |
| </div> |
| ${selectedContext.contextType === 'realtime' ? html` |
| <div class="context-detail-row"> |
| <div class="context-detail-row-entry">${i18nString(UIStrings.callbackBufferSize)}</div> |
| <div class="context-detail-row-value">${selectedContext.callbackBufferSize} frames</div> |
| </div>` : ''} |
| <div class="context-detail-row"> |
| <div class="context-detail-row-entry">${i18nString(UIStrings.maxOutputChannels)}</div> |
| <div class="context-detail-row-value">${selectedContext.maxOutputChannelCount} ch</div> |
| </div> |
| </div> |
| </div>`} |
| <div class="web-audio-summary-container"> |
| ${contextRealtimeData ? |
| html`<div class="context-summary-container"> |
| <span>${i18nString(UIStrings.currentTime)}: ${contextRealtimeData.currentTime.toFixed(3)} s</span> |
| <span>\u2758</span> |
| <span>${i18nString(UIStrings.callbackInterval)}: μ = ${ |
| (contextRealtimeData.callbackIntervalMean * 1000).toFixed(3)} ms, σ = ${ |
| (Math.sqrt(contextRealtimeData.callbackIntervalVariance) * 1000).toFixed(3)} ms</span> |
| <span>\u2758</span> |
| <span>${i18nString(UIStrings.renderCapacity)}: ${ |
| (contextRealtimeData.renderCapacity * 100).toFixed(3)} %</span> |
| </div>` : ''} |
| </div> |
| </div>`, target); |
| // clang-format on |
| }; |
| |
| export class WebAudioView extends UI.Widget.VBox implements SDK.TargetManager.SDKModelObserver<WebAudioModel> { |
| private readonly knownContexts = new Set<string>(); |
| private readonly contextSelectorItems: UI.ListModel.ListModel<Protocol.WebAudio.BaseAudioContext>; |
| private contextRealtimeData: Protocol.WebAudio.ContextRealtimeData|null = null; |
| private readonly view: View; |
| private selectedContextIndex = -1; |
| private readonly pollRealtimeDataThrottler: Common.Throttler.Throttler; |
| |
| constructor(element?: HTMLElement, view = DEFAULT_VIEW) { |
| super({jslog: `${VisualLogging.panel('web-audio').track({resize: true})}`, useShadowDom: true}); |
| this.view = view; |
| |
| this.contextSelectorItems = new UI.ListModel.ListModel(); |
| this.contextSelectorItems.addEventListener(UI.ListModel.Events.ITEMS_REPLACED, this.requestUpdate, this); |
| |
| SDK.TargetManager.TargetManager.instance().observeModels(WebAudioModel, this); |
| this.pollRealtimeDataThrottler = new Common.Throttler.Throttler(1000); |
| this.performUpdate(); |
| } |
| |
| override wasShown(): void { |
| super.wasShown(); |
| for (const model of SDK.TargetManager.TargetManager.instance().models(WebAudioModel)) { |
| this.addEventListeners(model); |
| } |
| } |
| |
| override willHide(): void { |
| super.willHide(); |
| for (const model of SDK.TargetManager.TargetManager.instance().models(WebAudioModel)) { |
| this.removeEventListeners(model); |
| } |
| } |
| |
| modelAdded(webAudioModel: WebAudioModel): void { |
| if (this.isShowing()) { |
| this.addEventListeners(webAudioModel); |
| } |
| } |
| |
| modelRemoved(webAudioModel: WebAudioModel): void { |
| this.removeEventListeners(webAudioModel); |
| } |
| |
| override performUpdate(): void { |
| const input = { |
| contexts: [...this.contextSelectorItems], |
| selectedContextIndex: this.selectedContextIndex, |
| onContextSelectorSelectionChanged: this.onContextSelectorSelectionChanged.bind(this), |
| contextRealtimeData: this.contextRealtimeData, |
| }; |
| this.view(input, {}, this.contentElement); |
| } |
| |
| private addEventListeners(webAudioModel: WebAudioModel): void { |
| webAudioModel.ensureEnabled(); |
| webAudioModel.addEventListener(ModelEvents.CONTEXT_CREATED, this.contextCreated, this); |
| webAudioModel.addEventListener(ModelEvents.CONTEXT_DESTROYED, this.contextDestroyed, this); |
| webAudioModel.addEventListener(ModelEvents.CONTEXT_CHANGED, this.contextChanged, this); |
| webAudioModel.addEventListener(ModelEvents.MODEL_RESET, this.reset, this); |
| } |
| |
| private removeEventListeners(webAudioModel: WebAudioModel): void { |
| webAudioModel.removeEventListener(ModelEvents.CONTEXT_CREATED, this.contextCreated, this); |
| webAudioModel.removeEventListener(ModelEvents.CONTEXT_DESTROYED, this.contextDestroyed, this); |
| webAudioModel.removeEventListener(ModelEvents.CONTEXT_CHANGED, this.contextChanged, this); |
| webAudioModel.removeEventListener(ModelEvents.MODEL_RESET, this.reset, this); |
| } |
| |
| private onContextSelectorSelectionChanged(contextId: string): void { |
| this.selectedContextIndex = this.contextSelectorItems.findIndex(context => context.contextId === contextId); |
| void this.pollRealtimeDataThrottler.schedule(this.pollRealtimeData.bind(this)); |
| this.requestUpdate(); |
| } |
| |
| private contextCreated(event: Common.EventTarget.EventTargetEvent<Protocol.WebAudio.BaseAudioContext>): void { |
| const context = event.data; |
| this.knownContexts.add(context.contextId); |
| this.contextSelectorItems.insert(this.contextSelectorItems.length, context); |
| if (this.selectedContextIndex === -1) { |
| this.selectedContextIndex = this.contextSelectorItems.length - 1; |
| void this.pollRealtimeDataThrottler.schedule(this.pollRealtimeData.bind(this)); |
| } |
| this.requestUpdate(); |
| } |
| |
| private contextDestroyed(event: Common.EventTarget.EventTargetEvent<Protocol.WebAudio.GraphObjectId>): void { |
| const contextId = event.data; |
| this.knownContexts.delete(contextId); |
| const index = this.contextSelectorItems.findIndex(context => context.contextId === contextId); |
| if (index > -1) { |
| const selectedContext = |
| this.selectedContextIndex > -1 ? this.contextSelectorItems.at(this.selectedContextIndex) : null; |
| this.contextSelectorItems.remove(index); |
| const newSelectedIndex = selectedContext ? this.contextSelectorItems.indexOf(selectedContext) : -1; |
| if (newSelectedIndex > -1) { |
| this.selectedContextIndex = newSelectedIndex; |
| } else { |
| this.selectedContextIndex = Math.min(index, this.contextSelectorItems.length - 1); |
| } |
| } |
| this.requestUpdate(); |
| } |
| |
| private contextChanged(event: Common.EventTarget.EventTargetEvent<Protocol.WebAudio.BaseAudioContext>): void { |
| const context = event.data; |
| if (!this.knownContexts.has(context.contextId)) { |
| return; |
| } |
| |
| const changedContext = event.data; |
| const index = this.contextSelectorItems.findIndex(context => context.contextId === changedContext.contextId); |
| if (index > -1) { |
| this.contextSelectorItems.replace(index, changedContext); |
| } |
| this.requestUpdate(); |
| } |
| |
| private reset(): void { |
| this.contextSelectorItems.replaceAll([]); |
| this.selectedContextIndex = -1; |
| |
| this.knownContexts.clear(); |
| this.requestUpdate(); |
| } |
| |
| private setContextRealtimeData(contextRealtimeData: Protocol.WebAudio.ContextRealtimeData|null): void { |
| this.contextRealtimeData = contextRealtimeData; |
| this.requestUpdate(); |
| } |
| |
| private async pollRealtimeData(): Promise<void> { |
| if (this.selectedContextIndex < 0) { |
| this.setContextRealtimeData(null); |
| return; |
| } |
| |
| const context = this.contextSelectorItems.at(this.selectedContextIndex); |
| if (!context) { |
| this.setContextRealtimeData(null); |
| return; |
| } |
| |
| for (const model of SDK.TargetManager.TargetManager.instance().models(WebAudioModel)) { |
| // Display summary only for real-time context. |
| if (context.contextType === 'realtime') { |
| if (!this.knownContexts.has(context.contextId)) { |
| continue; |
| } |
| const realtimeData = await model.requestRealtimeData(context.contextId); |
| if (realtimeData) { |
| this.setContextRealtimeData(realtimeData); |
| } |
| void this.pollRealtimeDataThrottler.schedule(this.pollRealtimeData.bind(this)); |
| } else { |
| this.setContextRealtimeData(null); |
| } |
| } |
| } |
| } |