#!/usr/bin/env python3 """Assemble the standalone single-file BWF Analyser. Reads the original plugin's CSS/JS (unmodified), the ported markup, the generated l10n object and a copy of jsPDF, and writes one index.html that runs with no network access at all. python3 build/build.py -> ./index.html (open in a browser) python3 build/build.py --tauri -> ./mac-app/dist/index.html (Tauri frontend) The Tauri variant swaps the file:// permission warning for the native bridge, which routes every read and write through Rust. """ import base64 import json import pathlib import sys TAURI = "--tauri" in sys.argv BUILD = pathlib.Path(__file__).resolve().parent SRC = BUILD / "src" OUT = (BUILD.parent / "mac-app" / "dist" / "index.html") if TAURI else (BUILD.parent / "index.html") def read(path): return pathlib.Path(path).read_text(encoding="utf-8") # The rewritten PDF writer is 200 lines of drawing code, which is unreadable and # undiffable as a patch string. It lives in build/pdf-writer.js and is read from # there — that file IS the shipped writer, not a copy of it. PDF_WRITER_JS = read(BUILD / "pdf-writer.js") FRAME_RATE_WRITER_JS = r''' /* ------------------------------------------------------------------ */ /* Frame rate is carried in more than one place */ /* ------------------------------------------------------------------ */ /* * A frame rate in a BWF file is not one field, it is up to five, and * different software reads different ones. Writing only TIMECODE_RATE * — and writing it as a decimal — produced files that this tool read * back happily and other tools showed as blank. * * What the standards actually say: * * - iXML SPEED/TIMECODE_RATE is a *rational*: "30/1", and 29.97 is * exactly 30000/1001, never "29.97". A strict reader rejects the * decimal form rather than guessing, which is why the field came * up empty elsewhere. * - TIMECODE_FLAG (NDF/DF) belongs with it. A rate with no flag is * incomplete, and drop-frame is only meaningful on the 1000/1001 * rates — DF on 25fps is nonsense. * - MASTER_SPEED and CURRENT_SPEED are part of the same block, and * some readers take the frame rate from MASTER_SPEED instead. * - bext has no frame-rate field at all; EBU 3285 never defined one. * What it has is a 256-byte free-text Description, into which * Sound Devices-style recorders pack tags like "aSPEED=025.000-ND". * Plenty of software reads that tag, so leaving it stale makes the * file contradict its own iXML. */ var FRAME_RATE_RATIONALS = { "23.976": "24000/1001", "24": "24/1", "25": "25/1", "29.97": "30000/1001", "30": "30/1", "47.952": "48000/1001", "48": "48/1", "50": "50/1", "59.94": "60000/1001", "60": "60/1" }; /** Drop-frame exists to reconcile a 1000/1001 rate with a wall clock. * On any other rate the flag is meaningless. */ function rateSupportsDropFrame( fps ) { return Math.abs( fps - 30000 / 1001 ) < 0.01 || Math.abs( fps - 60000 / 1001 ) < 0.01; } function rationalForRate( value ) { if ( value === null || value === undefined ) { return null; } var text = String( value ).trim(); if ( ! text.length ) { return null; } if ( text.indexOf( "/" ) !== -1 ) { return text; // Already a rational; leave the file's own form alone. } if ( FRAME_RATE_RATIONALS[ text ] ) { return FRAME_RATE_RATIONALS[ text ]; } var fps = parseFloat( text ); if ( ! fps || isNaN( fps ) ) { return null; } // An unlisted rate still gets a rational rather than a decimal: // near-integer rates are n/1, everything else is read as the // 1000/1001 pulldown of the nearest integer. if ( Math.abs( fps - Math.round( fps ) ) < 0.001 ) { return Math.round( fps ) + "/1"; } return ( Math.round( fps * 1001 / 1000 ) * 1000 ) + "/1001"; } function rateFromRational( text ) { var parts = String( text || "" ).split( "/" ); if ( parts.length === 2 ) { var numerator = parseFloat( parts[ 0 ] ); var denominator = parseFloat( parts[ 1 ] ); return denominator ? numerator / denominator : null; } var value = parseFloat( text ); return isNaN( value ) ? null : value; } var DESCRIPTION_SPEED_RE = /(SPEED\s*=\s*)(\d+)(?:(\.)(\d+))?([\s-]*)(NDF|DF|ND|D)?/i; /** * Rewrites just the number and the NDF/DF token inside a recorder's * own SPEED tag, keeping its prefix letter, digit padding and * separator exactly as they were — which also keeps the Description * the same length, so it stays an in-place byte patch. Returns null * when there's no such tag: a file that never had one doesn't get one * invented. */ function syncSpeedTagInDescription( description, fps, isDropFrame ) { if ( ! description || ! DESCRIPTION_SPEED_RE.test( description ) ) { return null; } return description.replace( DESCRIPTION_SPEED_RE, function ( whole, head, intPart, dot, decPart, separator, flag ) { var decimals = decPart ? decPart.length : 0; var parts = fps.toFixed( decimals ).split( "." ); var intText = parts[ 0 ]; while ( intText.length < intPart.length ) { intText = "0" + intText; } var out = head + intText + ( decimals ? "." + parts[ 1 ] : "" ) + separator; if ( flag ) { // The tokens in the wild are ND/DF (Sound Devices, // Deity), sometimes NDF for non-drop, occasionally a // bare D. "DF" is used by all of them for drop, so it // says nothing about which non-drop spelling a file // prefers — only an explicit NDF does. var existing = flag.toUpperCase(); if ( isDropFrame ) { out += existing === "D" ? "D" : "DF"; } else { out += existing === "NDF" ? "NDF" : "ND"; } } return out; } ); } function closeRates( a, b ) { return a !== null && a !== undefined && b !== null && b !== undefined && Math.abs( a - b ) < 0.01; } /** * Expands "the user set the frame rate" into every write that implies. * * Called with the file as it is on disk right now, so decisions are * made against real current values rather than whatever was loaded * when the folder was opened. */ function expandFrameRateEdits( freshParsed, edits ) { var ixmlEdits = ( edits.ixmlFieldEdits || [] ).slice(); var rateEdit = null; var flagEdit = null; ixmlEdits.forEach( function ( edit ) { var leaf = edit.path[ edit.path.length - 1 ]; if ( leaf === "TIMECODE_RATE" ) { rateEdit = edit; } else if ( leaf === "TIMECODE_FLAG" ) { flagEdit = edit; } } ); if ( ! rateEdit || ! String( rateEdit.value || "" ).length ) { return edits; // Nothing rate-related in this save. } var rational = rationalForRate( rateEdit.value ); if ( ! rational ) { return edits; } var fps = rateFromRational( rational ); if ( ! fps ) { return edits; } var speed = ( freshParsed.ixml && freshParsed.ixml.speed ) || {}; // The form pre-fills the rate select from the file, so this runs on // every save, not only when the rate was touched. Anything beyond // normalising the rate itself is therefore gated on the rate // actually changing — saving a scene name shouldn't rewrite the // SPEED block. var rateIsChanging = ! closeRates( speed.timecodeRate, fps ); var expanded = { ixmlFieldEdits: ixmlEdits.map( function ( edit ) { return edit === rateEdit ? { path: edit.path, value: rational } : edit; } ), descriptionText: edits.descriptionText }; ixmlEdits = expanded.ixmlFieldEdits; // The flag: fix an impossible one, supply a missing one, otherwise // leave the file's own alone. var existingFlag = String( speed.timecodeFlag || "" ).trim(); var wantedFlag = null; if ( flagEdit && String( flagEdit.value || "" ).length ) { wantedFlag = /^df$/i.test( String( flagEdit.value ).trim() ) ? "DF" : "NDF"; } else if ( ! existingFlag.length ) { wantedFlag = "NDF"; } else if ( /^df$/i.test( existingFlag ) && ! rateSupportsDropFrame( fps ) ) { wantedFlag = "NDF"; } if ( wantedFlag === "DF" && ! rateSupportsDropFrame( fps ) ) { wantedFlag = "NDF"; } var effectiveFlag = wantedFlag || ( /^df$/i.test( existingFlag ) ? "DF" : "NDF" ); if ( wantedFlag ) { if ( flagEdit ) { ixmlEdits = ixmlEdits.map( function ( edit ) { return edit === flagEdit ? { path: edit.path, value: wantedFlag } : edit; } ); } else { ixmlEdits.push( { path: [ "SPEED", "TIMECODE_FLAG" ], value: wantedFlag } ); } } if ( rateIsChanging ) { // MASTER_SPEED and CURRENT_SPEED are only safe to touch when // they agree with each other and with the rate being replaced. // When they disagree they describe a pull-up/pull-down // relationship, and flattening that would destroy real // information about how the file was recorded. var master = speed.masterSpeed; var current = speed.currentSpeed; var bothMissing = ( master === null || master === undefined ) && ( current === null || current === undefined ); var agreed = closeRates( master, current ) && ( speed.timecodeRate === null || speed.timecodeRate === undefined || closeRates( master, speed.timecodeRate ) ); if ( bothMissing || agreed ) { ixmlEdits.push( { path: [ "SPEED", "MASTER_SPEED" ], value: rational } ); ixmlEdits.push( { path: [ "SPEED", "CURRENT_SPEED" ], value: rational } ); } var baseDescription = expanded.descriptionText !== undefined ? expanded.descriptionText : ( ( freshParsed.bext && freshParsed.bext.description ) || "" ); var syncedDescription = syncSpeedTagInDescription( baseDescription, fps, effectiveFlag === "DF" ); if ( syncedDescription !== null && syncedDescription !== baseDescription ) { expanded.descriptionText = syncedDescription; } } expanded.ixmlFieldEdits = ixmlEdits; return expanded; } ''' EXPORT_FIELDS_JS = r''' /* ------------------------------------------------------------------ */ /* Which fields the exports carry */ /* ------------------------------------------------------------------ */ /* * CSV_COLUMNS is the full set of everything the analyser can report, so * it doubles as the master list for the picker. The PDF draws from the * same selection: a sound report is a sound report whichever format it * lands in. PDF-specific labels, widths and renderers are looked up by * key, and widths are renormalised to whatever the user chose. */ var EXPORT_FIELDS_STORAGE_KEY = "bwfa_export_fields_v1"; /** Set by the export handlers just before building, so two analysers on * one page each export their own selection. */ var activeExportFields = null; /* * Who the report is for. A sound report is a delivery document, so it * carries the production, the director and how to reach the mixer — none * of which is in the files. Typed once and remembered, because it's the * same answers every day of the same job. */ var REPORT_STORAGE_KEY = "bwfa_report_details_v1"; var REPORT_FIELDS = [ { key: "company", label: "Production company" }, { key: "project", label: "Project / show" }, { key: "director", label: "Director" }, { key: "mixer", label: "Sound mixer" }, { key: "phone", label: "Mixer phone" }, { key: "email", label: "Mixer email" }, { key: "note", label: "Note" } ]; var activeReportDetails = {}; function loadReportDetails( storage ) { if ( ! storage ) { return {}; } try { var raw = storage.getItem( REPORT_STORAGE_KEY ); var parsed = raw ? JSON.parse( raw ) : {}; return ( parsed && typeof parsed === "object" ) ? parsed : {}; } catch ( e ) { return {}; } } function saveReportDetails( details, storage ) { if ( ! storage ) { return; } try { storage.setItem( REPORT_STORAGE_KEY, JSON.stringify( details ) ); } catch ( e ) { // Private browsing, or a full quota. Not worth interrupting an // export over. } } /** The filled-in details, in the order the report should list them. */ function reportDetailPairs() { return REPORT_FIELDS.filter( function ( field ) { return String( ( activeReportDetails || {} )[ field.key ] || "" ).trim().length; } ).map( function ( field ) { return [ field.label, String( activeReportDetails[ field.key ] ).trim() ]; } ); } function defaultExportFieldKeys() { // Everything, deliberately. A default that quietly drops columns from // an export someone already relies on is the worse failure: an // over-wide PDF is obvious and one click from fixed, a CSV missing // Track Names might go unnoticed for months. The picker is where you // trim it down to a printable report. return CSV_COLUMNS.map( function ( col ) { return col.key; } ); } function loadExportFields( storage ) { var defaults = defaultExportFieldKeys(); if ( ! storage ) { return new Set( defaults ); } try { var raw = storage.getItem( EXPORT_FIELDS_STORAGE_KEY ); if ( ! raw ) { return new Set( defaults ); } var parsed = JSON.parse( raw ); if ( ! Array.isArray( parsed ) ) { return new Set( defaults ); } var valid = CSV_COLUMNS.map( function ( col ) { return col.key; } ); // An empty saved list is a real choice the picker allows only // transiently; on reload fall back rather than export nothing. var filtered = parsed.filter( function ( key ) { return valid.indexOf( key ) !== -1; } ); return new Set( filtered.length ? filtered : defaults ); } catch ( e ) { return new Set( defaults ); } } function saveExportFields( selection, storage ) { if ( ! storage ) { return; } try { storage.setItem( EXPORT_FIELDS_STORAGE_KEY, JSON.stringify( Array.from( selection ) ) ); } catch ( e ) { // Storage full or disabled: the choice just won't outlive the session. } } /** Filters a column list by the active selection, preserving the list's * own order. With no selection set, nothing changes. */ function selectedExportColumns( columns ) { if ( ! activeExportFields ) { return columns; } var kept = columns.filter( function ( col ) { return activeExportFields.has( col.key ); } ); return kept.length ? kept : columns; } /** * Builds the PDF's column list from the shared selection: PDF label, * width and renderer where one exists, the CSV label otherwise, and * widths rescaled so they still fill the page. */ /** * What to call an export. A report for a day's card should be named after * the card, not after the tool: "PR-2 2026-08-12.csv" beats * "bwf-metadata-2026-08-12.csv" the moment you have two of them. * * The app build knows the folder name outright (the bridge parks it on * window when you open one). In a browser, a folder pick puts the folder * at the front of every file's relative path, so the first row can be * asked instead. */ function exportBaseName( rows ) { var name = window.BWFA_FOLDER_NAME || ""; if ( ! name && rows && rows.length ) { var relative = String( rows[ 0 ].relativePath || "" ); var parts = relative.split( "/" ); if ( parts.length > 1 ) { name = parts[ 0 ]; } } // Collapse anything a filesystem would rather not see, along with the // runs of spaces some recorders leave in their filenames. name = String( name ).replace( /[\/\\:*?"<>|]+/g, "-" ).replace( /\s+/g, " " ).trim(); return name || "bwf-metadata"; } function pdfColumnsForSelection() { var byKey = {}; PDF_COLUMNS.forEach( function ( col ) { byKey[ col.key ] = col; } ); var chosen = selectedExportColumns( CSV_COLUMNS ).map( function ( col ) { var pdfCol = byKey[ col.key ]; return { key: col.key, label: pdfCol ? pdfCol.label : col.label, render: pdfCol ? pdfCol.render : undefined, width: pdfCol ? pdfCol.width : 0.1 }; } ); var total = chosen.reduce( function ( sum, col ) { return sum + col.width; }, 0 ); if ( ! total ) { return chosen; } return chosen.map( function ( col ) { return { key: col.key, label: col.label, render: col.render, width: col.width / total }; } ); } ''' EXPORT_PICKER_JS = r''' /* ---- export field picker ---- */ var exportFieldsToggle = container.querySelector( "[data-bwfa-export-fields-toggle]" ); var exportModal = container.querySelector( "[data-bwfa-export-modal]" ); var exportBackdrop = container.querySelector( "[data-bwfa-export-backdrop]" ); var exportFieldsWrap = container.querySelector( "[data-bwfa-export-fields]" ); var exportCountEl = container.querySelector( "[data-bwfa-export-count]" ); function renderExportFields() { if ( ! exportFieldsWrap ) { return; } exportFieldsWrap.textContent = ""; CSV_COLUMNS.forEach( function ( col ) { var label = document.createElement( "label" ); label.className = "bwfa-export-field"; var box = document.createElement( "input" ); box.type = "checkbox"; box.checked = state.exportFields.has( col.key ); box.setAttribute( "data-bwfa-export-field", col.key ); box.addEventListener( "change", function () { if ( box.checked ) { state.exportFields.add( col.key ); } else { state.exportFields.delete( col.key ); } saveExportFields( state.exportFields, storage ); updateExportCount(); } ); var text = document.createElement( "span" ); text.textContent = col.label; label.appendChild( box ); label.appendChild( text ); exportFieldsWrap.appendChild( label ); } ); updateExportCount(); } function updateExportCount() { if ( exportCountEl ) { exportCountEl.textContent = state.exportFields.size + " of " + CSV_COLUMNS.length + " fields"; } } function setExportFields( keys ) { state.exportFields = new Set( keys ); saveExportFields( state.exportFields, storage ); renderExportFields(); } function closeExportModal() { if ( exportModal ) { exportModal.hidden = true; exportModal.classList.remove( "open" ); } if ( exportBackdrop ) { exportBackdrop.hidden = true; exportBackdrop.classList.remove( "open" ); } } function openExportModal() { renderExportFields(); if ( exportModal ) { exportModal.hidden = false; exportModal.classList.add( "open" ); } if ( exportBackdrop ) { exportBackdrop.hidden = false; exportBackdrop.classList.add( "open" ); } } if ( exportFieldsToggle ) { exportFieldsToggle.addEventListener( "click", openExportModal ); } /* ---- sound report ---- */ function reportInput( key ) { return container.querySelector( '[data-bwfa-report-field="' + key + '"]' ); } function fillReportInputs( details ) { REPORT_FIELDS.forEach( function ( field ) { var input = reportInput( field.key ); if ( input ) { input.value = details[ field.key ] || ""; } } ); var format = container.querySelector( "[data-bwfa-report-format]" ); if ( format && details.format ) { format.value = details.format; } } function readReportInputs() { var details = {}; REPORT_FIELDS.forEach( function ( field ) { var input = reportInput( field.key ); details[ field.key ] = input ? String( input.value || "" ).trim() : ""; } ); var format = container.querySelector( "[data-bwfa-report-format]" ); details.format = format ? format.value : "pdf"; return details; } // Whatever was typed last time, so the details survive a relaunch. activeReportDetails = loadReportDetails( storage ); fillReportInputs( activeReportDetails ); var reportOpenBtn = container.querySelector( "[data-bwfa-report-open]" ); if ( reportOpenBtn ) { reportOpenBtn.addEventListener( "click", function () { var subject = container.querySelector( "[data-bwfa-report-subject]" ); if ( subject ) { subject.textContent = state.filteredRows.length + ( state.filteredRows.length === 1 ? " file" : " files" ); } openExportModal(); } ); } // Escape closes it and Enter in any of the detail fields makes the // report, because a modal full of text inputs behaves like a form // whether or not it is one. document.addEventListener( "keydown", function ( e ) { if ( ! exportModal || exportModal.hidden ) { return; } if ( e.key === "Escape" ) { closeExportModal(); return; } if ( e.key === "Enter" && e.target && e.target.closest && e.target.closest( "[data-bwfa-report-field]" ) ) { e.preventDefault(); var create = container.querySelector( "[data-bwfa-report-create]" ); if ( create ) { create.click(); } } } ); var reportCreateBtn = container.querySelector( "[data-bwfa-report-create]" ); if ( reportCreateBtn ) { reportCreateBtn.addEventListener( "click", function () { var details = readReportInputs(); saveReportDetails( details, storage ); activeReportDetails = details; closeExportModal(); // Straight through the app's own export buttons rather than // around them: they already carry the naming, the save panel and // the native interception the app build adds. var target = container.querySelector( details.format === "csv" ? "[data-bwfa-export-csv]" : "[data-bwfa-export-pdf]" ); if ( target ) { target.click(); } } ); } container.querySelectorAll( "[data-bwfa-export-close]" ).forEach( function ( btn ) { btn.addEventListener( "click", closeExportModal ); } ); if ( exportBackdrop ) { exportBackdrop.addEventListener( "click", closeExportModal ); } var exportAllBtn = container.querySelector( "[data-bwfa-export-all]" ); if ( exportAllBtn ) { exportAllBtn.addEventListener( "click", function () { setExportFields( CSV_COLUMNS.map( function ( col ) { return col.key; } ) ); } ); } var exportNoneBtn = container.querySelector( "[data-bwfa-export-none]" ); if ( exportNoneBtn ) { exportNoneBtn.addEventListener( "click", function () { setExportFields( [] ); } ); } var exportDefaultsBtn = container.querySelector( "[data-bwfa-export-defaults]" ); if ( exportDefaultsBtn ) { exportDefaultsBtn.addEventListener( "click", function () { setExportFields( defaultExportFieldKeys() ); } ); } ''' # Correctness patches, applied to both builds: the analyser writes frame rate # the way the standards define it, in every place a reader might look, plus the # frame rate in the table and a picker for what the exports carry. SHARED_APP_JS_PATCHES = [ # ---- sample rate is a list, not a free field ------------------------ # # There are maybe nine sample rates in professional use and a typo in # this field is a file that lies about itself, so it's a menu. The pull # rates are in there because this is a location tool: 47952 and 48048 are # what a 0.1% pull looks like. ( """\tvar FRAME_RATE_OPTIONS = [ "23.976", "24", "25", "29.97", "30", "47.952", "48", "50", "59.94", "60" ];""", """\tvar FRAME_RATE_OPTIONS = [ "23.976", "24", "25", "29.97", "30", "47.952", "48", "50", "59.94", "60" ]; \t/** How a value reads in a menu: as a rate, a flag, or frames a second. */ \tfunction optionLabel( field, value ) { \t\tif ( field.key === "frameRateFlag" ) { \t\t\treturn t( value === "DF" ? "df" : "ndf" ); \t\t} \t\tif ( field.unit === "hz" ) { \t\t\t// Nobody says "forty-eight thousand hertz". \t\t\treturn ( Parser && Parser.formatSampleRate( parseFloat( value ) ) ) || value; \t\t} \t\treturn value + " fps"; \t} \tvar SAMPLE_RATE_OPTIONS = [ \t\t"32000", "44100", "47952", "48000", "48048", "88200", "96000", "176400", "192000" \t];""", ), ( """\t\t{ key: "tcSampleRate", labelKey: "fieldTcSampleRate", type: "number", ixmlPath: [ "SPEED", "TIMESTAMP_SAMPLE_RATE" ] }, \t\t{ key: "digitizerRate", labelKey: "fieldDigitizerRate", type: "number", ixmlPath: [ "SPEED", "DIGITIZER_SAMPLE_RATE" ] },""", """\t\t{ key: "tcSampleRate", labelKey: "fieldTcSampleRate", type: "select", unit: "hz", \t\t\tixmlPath: [ "SPEED", "TIMESTAMP_SAMPLE_RATE" ], options: SAMPLE_RATE_OPTIONS }, \t\t{ key: "digitizerRate", labelKey: "fieldDigitizerRate", type: "select", unit: "hz", \t\t\tixmlPath: [ "SPEED", "DIGITIZER_SAMPLE_RATE" ], options: SAMPLE_RATE_OPTIONS },""", ), # Both select renderers label their options in fps, which is right for # exactly one of the four fields that now use them. ( """\t\t\t\tfield.options.forEach( function ( optValue ) { \t\t\t\t\tvar opt = document.createElement( "option" ); \t\t\t\t\topt.value = optValue; \t\t\t\t\topt.textContent = ( field.key === "frameRateFlag" ) \t\t\t\t\t\t? t( optValue === "DF" ? "df" : "ndf" ) \t\t\t\t\t\t: ( optValue + " fps" ); \t\t\t\t\tinput.appendChild( opt ); \t\t\t\t} ); \t\t\t\tinput.value = currentValue || "";""", """\t\t\t\tfield.options.forEach( function ( optValue ) { \t\t\t\t\tvar opt = document.createElement( "option" ); \t\t\t\t\topt.value = optValue; \t\t\t\t\topt.textContent = optionLabel( field, optValue ); \t\t\t\t\tinput.appendChild( opt ); \t\t\t\t} ); \t\t\t\t// A file with a value nobody would pick from a menu still has to \t\t\t\t// show it, or the form would quietly offer to change it. \t\t\t\tif ( currentValue !== null && currentValue !== undefined && currentValue !== "" && \t\t\t\t\tfield.options.indexOf( String( currentValue ) ) === -1 ) { \t\t\t\t\tvar asRecorded = document.createElement( "option" ); \t\t\t\t\tasRecorded.value = String( currentValue ); \t\t\t\t\tasRecorded.textContent = optionLabel( field, String( currentValue ) ) + \t\t\t\t\t\t" " + t( "asRecorded" ); \t\t\t\t\tinput.appendChild( asRecorded ); \t\t\t\t} \t\t\t\tinput.value = currentValue || "";""", ), ( """\t\t\t\t\tfield.options.forEach( function ( optValue ) { \t\t\t\t\t\tvar opt = document.createElement( "option" ); \t\t\t\t\t\topt.value = optValue; \t\t\t\t\t\topt.textContent = ( field.key === "frameRateFlag" ) \t\t\t\t\t\t\t? t( optValue === "DF" ? "df" : "ndf" ) \t\t\t\t\t\t\t: ( optValue + " fps" ); \t\t\t\t\t\tinput.appendChild( opt ); \t\t\t\t\t} );""", """\t\t\t\t\tfield.options.forEach( function ( optValue ) { \t\t\t\t\t\tvar opt = document.createElement( "option" ); \t\t\t\t\t\topt.value = optValue; \t\t\t\t\t\topt.textContent = optionLabel( field, optValue ); \t\t\t\t\t\tinput.appendChild( opt ); \t\t\t\t\t} );""", ), # The columns control moves out of the toolbar and into the table itself: # the empty header cell above Play is exactly where you are when you want # it, and it costs the toolbar nothing. ( '''\t\t\ttr.appendChild( document.createElement( "th" ) ); // Play column''', '''\t\t\tvar actionsTh = document.createElement( "th" ); \t\t\tvar columnsCog = document.createElement( "button" ); \t\t\tcolumnsCog.type = "button"; \t\t\tcolumnsCog.className = "bwfa-columns-cog"; \t\t\tcolumnsCog.setAttribute( "data-bwfa-columns-open", "" ); \t\t\tcolumnsCog.setAttribute( "aria-label", t( "columnsBtn" ) ); \t\t\tcolumnsCog.title = t( "columnsBtn" ); \t\t\tactionsTh.appendChild( columnsCog ); \t\t\ttr.appendChild( actionsTh ); // Play column, and the columns cog''', ), # Its own modal, rather than the dropdown it used to hang off. The menu # markup moved wholesale, so renderColumnsMenu() is untouched. ( '''\t\trenderColumnsMenu();''', '''\t\trenderColumnsMenu(); \t\tvar columnsModal = container.querySelector( "[data-bwfa-columns-modal]" ); \t\tvar columnsBackdrop = container.querySelector( "[data-bwfa-columns-backdrop]" ); \t\tfunction setColumnsModal( open ) { \t\t\t[ columnsModal, columnsBackdrop ].forEach( function ( part ) { \t\t\t\tif ( ! part ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tpart.hidden = ! open; \t\t\t\tif ( open ) { \t\t\t\t\tpart.classList.add( "open" ); \t\t\t\t} else { \t\t\t\t\tpart.classList.remove( "open" ); \t\t\t\t} \t\t\t} ); \t\t} \t\t// Delegated: the cog is rebuilt every time the table head is drawn. \t\tcontainer.addEventListener( "click", function ( e ) { \t\t\tif ( ! e.target || ! e.target.closest ) { \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-columns-open]" ) ) { \t\t\t\te.preventDefault(); \t\t\t\te.stopPropagation(); \t\t\t\trenderColumnsMenu(); \t\t\t\tsetColumnsModal( true ); \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-columns-close]" ) || \t\t\t\te.target.closest( "[data-bwfa-columns-backdrop]" ) ) { \t\t\t\tsetColumnsModal( false ); \t\t\t} \t\t} ); \t\tdocument.addEventListener( "keydown", function ( e ) { \t\t\tif ( e.key === "Escape" && columnsModal && ! columnsModal.hidden ) { \t\t\t\tsetColumnsModal( false ); \t\t\t} \t\t} );''', ), # The player names the file that's playing, so it's the obvious place to # open that file's metadata from. Without it you scroll back up a long day # hunting for the row you started from. ( '''\t\tplayerPlayPauseBtn.addEventListener( "click", togglePlayPause );''', '''\t\tplayerPlayPauseBtn.addEventListener( "click", togglePlayPause ); \t\t/** The file the transport is on, playing or idle. */ \t\tfunction playerRow() { \t\t\tif ( state.playback && state.playback.row ) { \t\t\t\treturn state.playback.row; \t\t\t} \t\t\treturn ( state.lastPlayed && state.lastPlayed.row ) || null; \t\t} \t\tstate.playerRow = playerRow; \t\tvar playerEditBtn = container.querySelector( "[data-bwfa-player-edit]" ); \t\tif ( playerEditBtn ) { \t\t\tplayerEditBtn.addEventListener( "click", function () { \t\t\t\t// Whatever the transport is holding, playing or not: a file that \t\t\t\t// has just finished is exactly the one you want to annotate. \t\t\t\tvar row = playerRow(); \t\t\t\tif ( row ) { \t\t\t\t\topenModal( row, playerEditBtn ); \t\t\t\t} \t\t\t} ); \t\t}''', ), # The table could report every field except the one this tool exists to # check most often. Start TC without its frame rate is half a reading. ( ''' { key: "startTimecode", label: t( "colTimecode" ), sortable: true },''', ''' { key: "startTimecode", label: t( "colTimecode" ), sortable: true }, { key: "frameRate", label: t( "colFrameRate" ), sortable: true },''', ), # Column visibility is remembered by key, and a list saved before the FPS # column existed would keep hiding it forever. Bumping the key retires # those saved lists once. ( ''' var VISIBLE_COLUMNS_STORAGE_KEY = "bwfa_visible_columns_v1";''', ''' var VISIBLE_COLUMNS_STORAGE_KEY = "bwfa_visible_columns_v2";''', ), ( ''' function buildCsv( rows ) {''', EXPORT_FIELDS_JS + ''' function buildCsv( rows ) {''', ), # Both exports draw their columns from the shared selection. ( ''' var lines = [ CSV_COLUMNS.map( function ( col ) { return csvEscape( col.label ); } ).join( "," ) ]; rows.forEach( function ( row ) { var line = CSV_COLUMNS.map( function ( col ) {''', ''' var columns = selectedExportColumns( CSV_COLUMNS ); var lines = [ columns.map( function ( col ) { return csvEscape( col.label ); } ).join( "," ) ]; rows.forEach( function ( row ) { var line = columns.map( function ( col ) {''', ), # The PDF writer, rewritten. The replacement is build/pdf-writer.js itself. ( '\tfunction buildPdf( rows ) {\n\t\tif ( ! window.jspdf || ! window.jspdf.jsPDF ) {\n\t\t\treturn null;\n\t\t}\n\n\t\tvar doc = new window.jspdf.jsPDF( { orientation: "landscape", unit: "pt", format: "a4" } );\n\t\tvar margin = 32;\n\t\tvar pageWidth = doc.internal.pageSize.getWidth();\n\t\tvar pageHeight = doc.internal.pageSize.getHeight();\n\t\tvar usableWidth = pageWidth - ( margin * 2 );\n\t\tvar rowHeight = 20;\n\t\tvar y = margin;\n\n\t\tfunction drawTitle() {\n\t\t\tdoc.setFont( "helvetica", "bold" );\n\t\t\tdoc.setFontSize( 14 );\n\t\t\tdoc.text( t( "pdfTitle" ), margin, y );\n\t\t\tdoc.setFont( "helvetica", "normal" );\n\t\t\tdoc.setFontSize( 9 );\n\t\t\tdoc.setTextColor( 100 );\n\t\t\tvar meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " +\n\t\t\t\tt( "pdfFileCount" ) + ": " + rows.length;\n\t\t\tdoc.text( meta, margin, y + 16 );\n\t\t\tdoc.setTextColor( 0 );\n\t\t\ty += 34;\n\t\t}\n\n\t\tfunction drawHeaderRow() {\n\t\t\tvar x = margin;\n\t\t\tdoc.setFillColor( 242, 242, 242 );\n\t\t\tdoc.rect( margin, y, usableWidth, rowHeight, "F" );\n\t\t\tdoc.setFont( "helvetica", "bold" );\n\t\t\tdoc.setFontSize( 8 );\n\t\t\tPDF_COLUMNS.forEach( function ( col ) {\n\t\t\t\tvar colWidth = usableWidth * col.width;\n\t\t\t\tdoc.text( col.label, x + 4, y + 14 );\n\t\t\t\tx += colWidth;\n\t\t\t} );\n\t\t\tdoc.setFont( "helvetica", "normal" );\n\t\t\ty += rowHeight;\n\t\t}\n\n\t\tfunction truncateToWidth( text, maxWidth ) {\n\t\t\tif ( doc.getTextWidth( text ) <= maxWidth ) {\n\t\t\t\treturn text;\n\t\t\t}\n\t\t\tvar truncated = text;\n\t\t\twhile ( truncated.length > 1 && doc.getTextWidth( truncated + "\\u2026" ) > maxWidth ) {\n\t\t\t\ttruncated = truncated.slice( 0, -1 );\n\t\t\t}\n\t\t\treturn truncated + "\\u2026";\n\t\t}\n\n\t\tfunction ensureSpace() {\n\t\t\tif ( y + rowHeight > pageHeight - margin ) {\n\t\t\t\tdoc.addPage();\n\t\t\t\ty = margin;\n\t\t\t\tdrawHeaderRow();\n\t\t\t}\n\t\t}\n\n\t\tdrawTitle();\n\t\tdrawHeaderRow();\n\t\tdoc.setFontSize( 8 );\n\n\t\trows.forEach( function ( row, rowIndex ) {\n\t\t\tensureSpace();\n\n\t\t\tif ( rowIndex % 2 === 1 ) {\n\t\t\t\tdoc.setFillColor( 250, 250, 250 );\n\t\t\t\tdoc.rect( margin, y, usableWidth, rowHeight, "F" );\n\t\t\t}\n\n\t\t\tvar x = margin;\n\t\t\tPDF_COLUMNS.forEach( function ( col ) {\n\t\t\t\tvar colWidth = usableWidth * col.width;\n\t\t\t\tvar raw = extract( row, col.key );\n\t\t\t\tvar text = col.render ? col.render( raw ) : ( raw === null || raw === undefined ? "-" : String( raw ) );\n\t\t\t\tdoc.text( truncateToWidth( text, colWidth - 8 ), x + 4, y + 14 );\n\t\t\t\tx += colWidth;\n\t\t\t} );\n\n\t\t\tdoc.setDrawColor( 225, 225, 225 );\n\t\t\tdoc.line( margin, y + rowHeight, margin + usableWidth, y + rowHeight );\n\n\t\t\ty += rowHeight;\n\t\t} );\n\n\t\tvar totalPages = doc.internal.getNumberOfPages();\n\t\tfor ( var p = 1; p <= totalPages; p++ ) {\n\t\t\tdoc.setPage( p );\n\t\t\tdoc.setFontSize( 8 );\n\t\t\tdoc.setTextColor( 130 );\n\t\t\tdoc.text( "Page " + p + " / " + totalPages, pageWidth - margin - 60, pageHeight - 14 );\n\t\t\tdoc.setTextColor( 0 );\n\t\t}\n\n\t\treturn doc;\n\t}\n', PDF_WRITER_JS, ), # Per-instance state, and the picker itself. ( ''' visibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ),''', ''' visibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ), exportFields: loadExportFields( storage ),''', ), ( ''' var searchDebounce = null;''', EXPORT_PICKER_JS + ''' var searchDebounce = null;''', ), # Exports are named after the folder they describe. ( ''' downloadBlob( new Blob( [ csv ], { type: "text/csv;charset=utf-8;" } ), "bwf-metadata-" + todayStamp() + ".csv" );''', ''' downloadBlob( new Blob( [ csv ], { type: "text/csv;charset=utf-8;" } ), exportBaseName( state.filteredRows ) + " " + todayStamp() + ".csv" );''', ), ( ''' doc.save( "bwf-metadata-report-" + todayStamp() + ".pdf" );''', ''' doc.save( exportBaseName( state.filteredRows ) + " " + todayStamp() + ".pdf" );''', ), ( ''' doc.setFontSize( 14 ); doc.text( t( "pdfTitle" ), margin, y );''', ''' doc.setFontSize( 14 ); // The folder name is the report's subject; the tool's name is not. doc.text( exportBaseName( rows ), margin, y );''', ), ( ''' var meta = t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " + t( "pdfFileCount" ) + ": " + rows.length;''', ''' var meta = t( "pdfTitle" ) + " " + t( "pdfGeneratedOn" ) + ": " + new Date().toLocaleString() + " " + t( "pdfFileCount" ) + ": " + rows.length;''', ), # Tell the builders whose selection to use. ( ''' var csv = buildCsv( state.filteredRows );''', ''' activeExportFields = state.exportFields; var csv = buildCsv( state.filteredRows );''', ), # The production details go above the table, as their own key/value block. # It stops the file being strict CSV, which is the convention every sound # report follows: a spreadsheet shows the header, a parser skips to the # blank line. ( r''' return "\ufeff" + lines.join( "\r\n" ); }''', r''' var pairs = reportDetailPairs(); if ( pairs.length ) { var head = pairs.map( function ( pair ) { return csvEscape( pair[ 0 ] ) + "," + csvEscape( pair[ 1 ] ); } ); head.push( "" ); lines = head.concat( lines ); } return "\ufeff" + lines.join( "\r\n" ); }''', ), ( ''' var doc = buildPdf( state.filteredRows );''', ''' activeExportFields = state.exportFields; var doc = buildPdf( state.filteredRows );''', ), ] FRAME_RATE_SHARED_PATCHES = [ ( ''' function writeMetadataToFile( row, edits ) {''', FRAME_RATE_WRITER_JS + ''' function writeMetadataToFile( row, edits ) {''', ), ( ''' var originalBytes = new Uint8Array( arrayBuffer ); var chunkPlan = freshParsed._chunkPlan;''', ''' var originalBytes = new Uint8Array( arrayBuffer ); var chunkPlan = freshParsed._chunkPlan; // A frame-rate change means more than one field; work // that out against the file as it is right now. edits = expandFrameRateEdits( freshParsed, edits );''', ), ] PLAYBACK_PATCHES = [ # ---- the audio session can be taken away from us ------------------- # # Reported as: play a file after the app has been open a while and the # transport runs but nothing comes out, until the app is restarted. # # A WKWebView doesn't own the machine's audio session. macOS takes it back # when the machine sleeps, when the output device changes (headphones, # an interface waking up, a Zoom call starting), or when another app asks # for it — and WebKit parks the AudioContext in "interrupted", a state the # spec doesn't have and the original code didn't check for: it resumed # only from "suspended". A context that never comes back plays silence # forever, which is exactly the reported symptom. # # So: resume from any state that isn't "running", listen for the context # changing state underneath us, and if a resume doesn't take, throw the # context away and build a new one — which is what restarting the app was # doing by hand. ( """\t\tfunction ensureAudioContext() { \t\t\tif ( ! state.audioContext ) { \t\t\t\tvar AudioContextCtor = window.AudioContext || window.webkitAudioContext; \t\t\t\tstate.audioContext = new AudioContextCtor(); \t\t\t} \t\t\tif ( state.audioContext.state === "suspended" ) { \t\t\t\tstate.audioContext.resume(); \t\t\t} \t\t\treturn state.audioContext; \t\t}""", """\t\t/** How long to give a resume before deciding the context is gone. */ \t\tvar AUDIO_LIMITS = window.BWFA_AUDIO_LIMITS || {}; \t\t/** How long to give a resume before deciding the context is gone. */ \t\tvar AUDIO_RECOVERY_DELAY = AUDIO_LIMITS.recoveryDelay || 400; \t\t/* \t\t * A context that has been sitting idle this long is rebuilt before the \t\t * next file rather than trusted. \t\t * \t\t * This is the fix for the one that kept coming back: laptop closed \t\t * overnight, opened in the morning, transport runs and no sound until \t\t * the app is relaunched. Nothing inside the page can see it — the \t\t * context resumes, reports "running" and its clock advances normally. \t\t * It is rendering perfectly into an output that isn't connected to \t\t * anything any more, and there is no API that will say so. \t\t * \t\t * So the app stops asking. A minute of nothing playing is enough to \t\t * make the audio path suspect, and building a fresh one costs a \t\t * millisecond or two. Relaunching the app worked because it built a new \t\t * context; this does the same thing without the relaunch. \t\t */ \t\tvar AUDIO_STALE_AFTER = AUDIO_LIMITS.staleAfter || 60000; \t\t/** A wall-clock jump this big means the machine slept, or we were suspended. */ \t\tvar AUDIO_SLEEP_GAP = AUDIO_LIMITS.sleepGap || 20000; \t\tfunction resumeAudioContext( context ) { \t\t\tif ( ! context || context.state === "running" || context.state === "closed" ) { \t\t\t\treturn; \t\t\t} \t\t\ttry { \t\t\t\tvar resuming = context.resume(); \t\t\t\tif ( resuming && resuming.catch ) { \t\t\t\t\tresuming.catch( function () {} ); \t\t\t\t} \t\t\t} catch ( e ) { \t\t\t\t// Nothing useful to do here; the watchdog rebuilds the context. \t\t\t} \t\t} \t\t/** \t\t * Drops the context, keeping what was decoded into it where that's safe. \t\t * \t\t * An AudioBuffer outlives the context that decoded it — they haven't been \t\t * tied together for years — so a rebuild doesn't have to mean decoding a \t\t * 4 GB take again. What it can't survive is a change of sample rate: the \t\t * same buffer on a 44.1k context after a 48k one plays at the wrong \t\t * speed. So the rate is remembered and the caches are cleared only if it \t\t * moves. \t\t */ \t\tfunction forgetAudioContext( dropDecoded ) { \t\t\tvar old = state.audioContext; \t\t\tstate.audioContext = null; \t\t\tstate.audioStale = false; \t\t\tif ( dropDecoded ) { \t\t\t\tstate.decodedBufferCache.clear(); \t\t\t\tstate.audioRate = 0; \t\t\t} else if ( old ) { \t\t\t\tstate.audioRate = old.sampleRate || 0; \t\t\t} \t\t\tif ( old && old.state !== "closed" ) { \t\t\t\ttry { \t\t\t\t\told.close(); \t\t\t\t} catch ( e ) { \t\t\t\t\t// Already gone. \t\t\t\t} \t\t\t} \t\t} \t\t/** \t\t * Something happened that the audio path might not have survived: the \t\t * machine slept, an output device came or went, the app was suspended. \t\t */ \t\tfunction markAudioStale() { \t\t\tstate.audioStale = true; \t\t\tif ( state.playback && state.playback.isPlaying ) { \t\t\t\trecoverAudio( "device" ); \t\t\t} else if ( state.audioContext ) { \t\t\t\tforgetAudioContext( false ); \t\t\t} \t\t} \t\tfunction ensureAudioContext() { \t\t\tvar idleFor = state.audioTouchedAt ? Date.now() - state.audioTouchedAt : 0; \t\t\tif ( idleFor > AUDIO_STALE_AFTER ) { \t\t\t\tstate.audioStale = true; \t\t\t} \t\t\tif ( state.audioContext && \t\t\t\t( state.audioContext.state === "closed" || state.audioStale ) ) { \t\t\t\tforgetAudioContext( false ); \t\t\t} \t\t\tif ( ! state.audioContext ) { \t\t\t\tvar AudioContextCtor = window.AudioContext || window.webkitAudioContext; \t\t\t\tstate.audioContext = new AudioContextCtor(); \t\t\t\t// Buffers decoded at another rate would play at the wrong speed. \t\t\t\tif ( state.audioRate && state.audioContext.sampleRate !== state.audioRate ) { \t\t\t\t\tstate.decodedBufferCache.clear(); \t\t\t\t\tstate.waveformPeaksCache.clear(); \t\t\t\t} \t\t\t\tstate.audioRate = state.audioContext.sampleRate || 0; \t\t\t\tstate.audioContext.addEventListener( "statechange", function () { \t\t\t\t\tvar context = state.audioContext; \t\t\t\t\tif ( context && context.state !== "running" && \t\t\t\t\t\tstate.playback && state.playback.isPlaying ) { \t\t\t\t\t\tresumeAudioContext( context ); \t\t\t\t\t} \t\t\t\t} ); \t\t\t} \t\t\tresumeAudioContext( state.audioContext ); \t\t\tstate.audioTouchedAt = Date.now(); \t\t\treturn state.audioContext; \t\t} \t\t/** \t\t * Rebuilds the audio path and picks the file up where it was. \t\t * \t\t * Rate-limited, because a machine with no working output at all would \t\t * otherwise sit here restarting the same file forever. \t\t */ \t\tfunction recoverAudio( why ) { \t\t\tvar now = Date.now(); \t\t\tif ( state.audioRebuiltAt && now - state.audioRebuiltAt < 5000 ) { \t\t\t\treturn; \t\t\t} \t\t\tstate.audioRebuiltAt = now; \t\t\tvar row = state.playback.row; \t\t\tvar offset = getElapsed(); \t\t\tstopPlayback(); \t\t\tforgetAudioContext( true ); \t\t\tstate.resumeAt = offset; \t\t\tsetStatus( t( "audioReopened" ) ); \t\t\tplayRow( row ); \t\t} \t\t/** \t\t * Whether the context is actually rendering, which is not the same \t\t * question as what `state` says. \t\t * \t\t * A context can sit at "running" with a dead output and then play \t\t * silence while the transport reports everything is fine. The one thing \t\t * that can't lie is the clock: if currentTime isn't moving, nothing is \t\t * being rendered, whatever the state says. (It can lie the other way — \t\t * a clock that moves doesn't prove anything reaches the speakers — which \t\t * is what the staleness rule above is for.) \t\t */ \t\tfunction checkAudioIsMoving() { \t\t\tvar context = state.audioContext; \t\t\tif ( ! context || ! state.playback || ! state.playback.isPlaying ) { \t\t\t\treturn; \t\t\t} \t\t\tstate.audioTouchedAt = Date.now(); \t\t\tvar probe = state.audioProbe; \t\t\tif ( ! probe || probe.context !== context ) { \t\t\t\tstate.audioProbe = { context: context, time: context.currentTime, wall: Date.now() }; \t\t\t\treturn; \t\t\t} \t\t\tvar wall = ( Date.now() - probe.wall ) / 1000; \t\t\tif ( wall < 0.6 ) { \t\t\t\treturn; // Too soon to tell. \t\t\t} \t\t\tvar moved = context.currentTime - probe.time; \t\t\tstate.audioProbe = { context: context, time: context.currentTime, wall: Date.now() }; \t\t\tif ( moved > wall * 0.25 ) { \t\t\t\treturn; // Rendering. \t\t\t} \t\t\trecoverAudio( "clock" ); \t\t} \t\t/* \t\t * Sleep, as seen from inside a page: wall-clock time jumps and nothing \t\t * else does. Timers don't run while the machine is asleep, so the first \t\t * tick after waking is late by however long the lid was shut. \t\t */ \t\tvar lastAudioTick = Date.now(); \t\twindow.setInterval( function () { \t\t\tvar now = Date.now(); \t\t\tvar gap = now - lastAudioTick; \t\t\tlastAudioTick = now; \t\t\tif ( gap > AUDIO_SLEEP_GAP ) { \t\t\t\tmarkAudioStale(); \t\t\t} \t\t}, 5000 ); \t\t// Headphones in, an interface waking up, a Bluetooth device connecting: \t\t// all reasons the output we opened is not the output we'd get now. \t\tif ( navigator.mediaDevices && navigator.mediaDevices.addEventListener ) { \t\t\tnavigator.mediaDevices.addEventListener( "devicechange", markAudioStale ); \t\t} \t\tfunction watchAudioContext() { \t\t\tvar context = state.audioContext; \t\t\tif ( ! context ) { \t\t\t\treturn; \t\t\t} \t\t\tstate.audioProbe = null; \t\t\twindow.setTimeout( function () { \t\t\t\tif ( context !== state.audioContext || ! state.playback || ! state.playback.isPlaying ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tif ( context.state === "running" ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\trecoverAudio( "state" ); \t\t\t}, AUDIO_RECOVERY_DELAY ); \t\t} \t\t// Coming back to the app is the moment to find out the session went \t\t// away, rather than the next time Play is pressed. \t\twindow.addEventListener( "focus", function () { \t\t\tresumeAudioContext( state.audioContext ); \t\t} ); \t\tdocument.addEventListener( "visibilitychange", function () { \t\t\tif ( ! document.hidden ) { \t\t\t\tresumeAudioContext( state.audioContext ); \t\t\t} \t\t} );""", ), ( """\t\t\t\tgraph.sourceNode.start( 0, 0 );""", """\t\t\t\tgraph.sourceNode.start( 0, 0 ); \t\t\t\twatchAudioContext();""", ), ( """\t\t\tfunction tick() { \t\t\t\tif ( ! state.playback || ! state.playback.isPlaying ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tupdateTransportDisplay();""", """\t\t\tfunction tick() { \t\t\t\tif ( ! state.playback || ! state.playback.isPlaying ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tcheckAudioIsMoving(); \t\t\t\tif ( ! state.playback || ! state.playback.isPlaying ) { \t\t\t\t\treturn; // Recovery tore the graph down from under this loop. \t\t\t\t} \t\t\t\tupdateTransportDisplay();""", ), # Recovery picks the file up where it stopped rather than at the top. ( """\t\t\t\tvar graph = buildPlaybackGraph( buffer ); \t\t\t\tstate.playback = {""", """\t\t\t\tvar graph = buildPlaybackGraph( buffer ); \t\t\t\tvar startAt = Math.min( state.resumeAt || 0, buffer.duration ); \t\t\t\tstate.resumeAt = 0; \t\t\t\tstate.playback = {""", ), ( """\t\t\t\t\toffset: 0, \t\t\t\t\tstartedAt: state.audioContext.currentTime,""", """\t\t\t\t\toffset: startAt, \t\t\t\t\tstartedAt: state.audioContext.currentTime,""", ), ( """\t\t\t\tgraph.sourceNode.start( 0, 0 ); \t\t\t\twatchAudioContext();""", """\t\t\t\tgraph.sourceNode.start( 0, startAt ); \t\t\t\twatchAudioContext();""", ), ( """\t\t\tgraph.sourceNode.start( 0, state.playback.offset );""", """\t\t\tensureAudioContext(); \t\t\tgraph.sourceNode.start( 0, state.playback.offset ); \t\t\twatchAudioContext();""", ), # ---- the player is furniture, not a popup -------------------------- # # It used to appear on the first Play and vanish when the file ended, so # the layout moved under you twice per audition and there was nothing to # press to hear the last file again. It's part of the results panel now: # up as soon as a folder is open, and after a file ends it stays, holding # that file ready to play again. ( """\t\t\tresultsEl.hidden = false;""", """\t\t\tresultsEl.hidden = false; \t\t\tplayerWrap.hidden = false; \t\t\tshowIdlePlayer( state.lastPlayed );""", ), ( """\t\tfunction stopPlayback() {""", """\t\t/** \t\t * The transport with nothing playing: the last file still named, ready \t\t * to go again, or an empty shell if nothing has played yet. \t\t */ \t\tfunction showIdlePlayer( last ) { \t\t\tvar row = last && last.row; \t\t\tplayerDecoding.hidden = true; \t\t\tplayerElapsedEl.textContent = "00:00:00"; \t\t\t// The length stays on screen: a file you just heard still has one, \t\t\t// and blanking it made the transport look broken. \t\t\tplayerDurationEl.textContent = ( last && last.duration ) \t\t\t\t? Parser.formatDuration( last.duration ) \t\t\t\t: "00:00:00"; \t\t\tplayerFilename.textContent = row ? row.parsed.fileName : t( "playerIdle" ); \t\t\tplayerPlayPauseBtn.disabled = ! row; \t\t\tvar badge = container.querySelector( "[data-bwfa-player-badge]" ); \t\t\tif ( badge ) { \t\t\t\tbadge.textContent = row ? t( "playerReady" ) : t( "playerNothing" ); \t\t\t} \t\t\tvar channelRow = container.querySelector( "[data-bwfa-channel-row]" ); \t\t\tif ( channelRow ) { \t\t\t\tchannelRow.hidden = true; \t\t\t} \t\t\tvar idleCtx = playerWaveformCanvas.getContext( "2d" ); \t\t\tif ( ! idleCtx ) { \t\t\t\treturn; \t\t\t} \t\t\tidleCtx.clearRect( 0, 0, playerWaveformCanvas.width, playerWaveformCanvas.height ); \t\t\tif ( last && last.peaks ) { \t\t\t\t// Waveform stays too, with the playhead back at the start. \t\t\t\tvar idleColors = getWaveformColors( playerWaveformCanvas ); \t\t\t\tdrawWaveformOnCanvas( \t\t\t\t\tidleCtx, playerWaveformCanvas.width, playerWaveformCanvas.height, \t\t\t\t\tlast.peaks, 0, idleColors.wave, idleColors.playhead \t\t\t\t); \t\t\t} \t\t} \t\tfunction stopPlayback() {""", ), ( """\t\t\tstate.playback = null; \t\t\tplayerWrap.hidden = true; \t\t\tplayerDecoding.hidden = true; \t\t\tchannelChipsWrap.textContent = ""; \t\t\tsyncPlaybackUI();""", """\t\t\tif ( state.playback ) { \t\t\t\t// Kept for the idle transport: the name, the length and the \t\t\t\t// waveform of the file you just heard. \t\t\t\tstate.lastPlayed = { \t\t\t\t\trow: state.playback.row, \t\t\t\t\tpeaks: state.playback.peaks, \t\t\t\t\tduration: state.playback.buffer ? state.playback.buffer.duration : 0 \t\t\t\t}; \t\t\t} \t\t\tstate.playback = null; \t\t\tchannelChipsWrap.textContent = ""; \t\t\t// The panel stays; only what it says changes. \t\t\tshowIdlePlayer( state.lastPlayed ); \t\t\tsyncPlaybackUI();""", ), # Play on the transport with nothing loaded plays the last file again. ( """\t\tfunction togglePlayPause() { \t\t\tif ( ! state.playback ) { \t\t\t\treturn; \t\t\t}""", """\t\tfunction togglePlayPause() { \t\t\tif ( ! state.playback ) { \t\t\t\tif ( state.lastPlayed && state.lastPlayed.row ) { \t\t\t\t\tplayRow( state.lastPlayed.row ); \t\t\t\t} \t\t\t\treturn; \t\t\t}""", ), ( """\t\t\tplayerWrap.hidden = false; \t\t\tplayerFilename.textContent = row.parsed.fileName;""", """\t\t\tplayerWrap.hidden = false; \t\t\tstate.lastPlayed = { row: row, peaks: null, duration: 0 }; \t\t\tplayerPlayPauseBtn.disabled = false; \t\t\tvar playingBadge = container.querySelector( "[data-bwfa-player-badge]" ); \t\t\tif ( playingBadge ) { \t\t\t\tplayingBadge.textContent = t( "playerPlaying" ); \t\t\t} \t\t\tvar chipsRow = container.querySelector( "[data-bwfa-channel-row]" ); \t\t\tif ( chipsRow ) { \t\t\t\tchipsRow.hidden = false; \t\t\t} \t\t\tplayerFilename.textContent = row.parsed.fileName;""", ), # ---- when a file finishes -------------------------------------- # # The graph's onended used to mean "stop, always". It's a setting now, # under the cog: stop and hold the file, work down the list, or loop. ( """\t\t\tsource.onended = function () { \t\t\t\tif ( source._expectingStop ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tif ( state.playback && state.playback.sourceNode === source ) { \t\t\t\t\tstopPlayback(); \t\t\t\t} \t\t\t};""", """\t\t\tsource.onended = function () { \t\t\t\tif ( source._expectingStop ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tif ( state.playback && state.playback.sourceNode === source ) { \t\t\t\t\tvar finished = state.playback.row; \t\t\t\t\tstopPlayback(); \t\t\t\t\tafterFileFinished( finished ); \t\t\t\t} \t\t\t};""", ), ( """\t\tfunction showIdlePlayer( last ) {""", """\t\t/** \t\t * What follows a file: nothing, the next one down the table, or the \t\t * same one again. "Next" walks the filtered, sorted rows — the order on \t\t * screen is the order you meant. \t\t */ \t\tfunction afterFileFinished( row ) { \t\t\tif ( state.playbackMode === "repeat" ) { \t\t\t\tplayRow( row ); \t\t\t\treturn; \t\t\t} \t\t\tif ( state.playbackMode !== "next" ) { \t\t\t\treturn; \t\t\t} \t\t\tvar list = state.filteredRows || []; \t\t\tfor ( var i = 0; i < list.length; i++ ) { \t\t\t\tif ( list[ i ]._id === row._id ) { \t\t\t\t\tif ( list[ i + 1 ] ) { \t\t\t\t\t\tplayRow( list[ i + 1 ] ); \t\t\t\t\t} \t\t\t\t\treturn; \t\t\t\t} \t\t\t} \t\t} \t\tfunction showIdlePlayer( last ) {""", ), # The setting itself, remembered between runs. ( """\t\trenderColumnsMenu();""", """\t\t/* ---- appearance ---- */ \t\tvar THEME_KEY = "bwfa_theme"; \t\tvar darkQuery = window.matchMedia ? window.matchMedia( "(prefers-color-scheme: dark)" ) : null; \t\tfunction applyTheme() { \t\t\tvar wanted = state.theme || "auto"; \t\t\tvar dark = wanted === "dark" || ( wanted === "auto" && darkQuery && darkQuery.matches ); \t\t\tcontainer.setAttribute( "data-bwfa-theme", dark ? "dark" : "light" ); \t\t\t// Tells the engine which way round the built-in furniture goes: \t\t\t// scrollbars, form controls, the flash of background on resize. \t\t\tif ( document.documentElement ) { \t\t\t\tdocument.documentElement.style.colorScheme = dark ? "dark" : "light"; \t\t\t} \t\t} \t\ttry { \t\t\tstate.theme = ( storage && storage.getItem( THEME_KEY ) ) || "auto"; \t\t} catch ( e ) { \t\t\tstate.theme = "auto"; \t\t} \t\tapplyTheme(); \t\tif ( darkQuery ) { \t\t\t// addListener is the old spelling, and WebKit kept it long after it \t\t\t// gained addEventListener. \t\t\tif ( darkQuery.addEventListener ) { \t\t\t\tdarkQuery.addEventListener( "change", applyTheme ); \t\t\t} else if ( darkQuery.addListener ) { \t\t\t\tdarkQuery.addListener( applyTheme ); \t\t\t} \t\t} \t\tcontainer.querySelectorAll( "[data-bwfa-theme-mode]" ).forEach( function ( radio ) { \t\t\tradio.checked = radio.value === state.theme; \t\t\tradio.addEventListener( "change", function () { \t\t\t\tif ( ! radio.checked ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tstate.theme = radio.value; \t\t\t\tapplyTheme(); \t\t\t\ttry { \t\t\t\t\tif ( storage ) { \t\t\t\t\t\tstorage.setItem( THEME_KEY, radio.value ); \t\t\t\t\t} \t\t\t\t} catch ( e ) { \t\t\t\t\t// Private browsing, or a full quota. \t\t\t\t} \t\t\t} ); \t\t} ); \t\t// The last resort, for when the output is gone in a way nothing in the \t\t// page can see or fix: throw the audio path away and reload. Hidden in \t\t// the browser build, where a reload loses the files as well. \t\tvar audioResetBtn = container.querySelector( "[data-bwfa-audio-reset]" ); \t\tif ( audioResetBtn ) { \t\t\taudioResetBtn.addEventListener( "click", function () { \t\t\t\tstopPlayback(); \t\t\t\tforgetAudioContext( true ); \t\t\t\t// A reload is the wrong instrument and always was: it builds \t\t\t\t// a new document and a new audio context, and the sound stays \t\t\t\t// gone. Only a new process gets a new output, so that is what \t\t\t\t// this does where it can. The browser build has nothing else. \t\t\t\tvar tauri = window.__TAURI__; \t\t\t\tif ( tauri && tauri.core ) { \t\t\t\t\ttauri.core.invoke( \"bwf_restart\" ).catch( function () { \t\t\t\t\t\twindow.location.reload(); \t\t\t\t\t} ); \t\t\t\t\treturn; \t\t\t\t} \t\t\t\twindow.location.reload(); \t\t\t} ); \t\t} \t\tvar PLAYBACK_MODE_KEY = "bwfa_playback_mode"; \t\ttry { \t\t\tstate.playbackMode = ( storage && storage.getItem( PLAYBACK_MODE_KEY ) ) || "stop"; \t\t} catch ( e ) { \t\t\tstate.playbackMode = "stop"; \t\t} \t\tcontainer.querySelectorAll( "[data-bwfa-playback-mode]" ).forEach( function ( radio ) { \t\t\tradio.checked = radio.value === state.playbackMode; \t\t\tradio.addEventListener( "change", function () { \t\t\t\tif ( ! radio.checked ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tstate.playbackMode = radio.value; \t\t\t\ttry { \t\t\t\t\tif ( storage ) { \t\t\t\t\t\tstorage.setItem( PLAYBACK_MODE_KEY, radio.value ); \t\t\t\t\t} \t\t\t\t} catch ( e ) { \t\t\t\t\t// Private browsing, or a full quota. \t\t\t\t} \t\t\t} ); \t\t} ); \t\trenderColumnsMenu();""", ), # Clear takes the results away, and the transport is part of them. ( """\t\t\tresultsEl.hidden = true;""", """\t\t\tresultsEl.hidden = true; \t\t\tplayerWrap.hidden = true; \t\t\tstate.lastPlayed = null;""", ), # A file that won't decode leaves the transport up and idle rather than # taking the panel away with it. ( """\t\t\t\tplayerDecoding.hidden = true; \t\t\t\thandlePlaybackError(); \t\t\t\tplayerWrap.hidden = true;""", """\t\t\t\tplayerDecoding.hidden = true; \t\t\t\thandlePlaybackError(); \t\t\t\tshowIdlePlayer( null );""", ), ] SHARED_APP_JS_PATCHES = SHARED_APP_JS_PATCHES + FRAME_RATE_SHARED_PATCHES + PLAYBACK_PATCHES # Tauri-only patches to the analyser's JavaScript. The file in build/src stays # exactly as the plugin ships it; these are applied on the way into the app # build, and every one has to match or the build stops — a silent no-op here # would look like a UI regression later. # ---- playback moves out of the webview ------------------------------ # # WebKit's audio dies after the machine has been left alone: the transport # runs, the clock advances, nothing reaches the speakers, and only quitting # the app brings it back. A page reload builds a brand new AudioContext and is # still silent, which puts the fault below the page, in the content process # that renders our audio. Nothing in JavaScript can reach it. # # So in the Mac app the page stops making sound. It asks Rust to, and reads # the position back off an event. The browser build keeps Web Audio, having # nothing else, and it is not the build anybody leaves open overnight. # The spectrogram is a Rust job like the waveform: one streaming pass, one # column per pixel, so a four-hour day file costs a read rather than its own # weight in memory. The page only paints what comes back. SPECTRO_PATCHES = [ ( """\t\tfunction startTransportLoop() {""", """\t\t/* ---- the spectrogram ------------------------------------- \t\t Time across, frequency up, brightness as level. It follows the \t\t channel chips: solo the boom and you see the boom rather than \t\t the mono sum, so you are looking at what you are hearing. \t\t ------------------------------------------------------------ */ \t\tvar spectroModal = container.querySelector( "[data-bwfa-spectro]" ); \t\tvar spectroBackdrop = container.querySelector( "[data-bwfa-spectro-backdrop]" ); \t\tvar spectroCanvas = container.querySelector( "[data-bwfa-spectro-canvas]" ); \t\tvar spectroNote = container.querySelector( "[data-bwfa-spectro-note]" ); \t\tvar spectroTitle = container.querySelector( "[data-bwfa-spectro-title]" ); \t\tvar spectroScale = container.querySelector( "[data-bwfa-spectro-scale]" ); \t\t/** \t\t * Viridis, sampled. Perceptually even, so a bright patch means a loud \t\t * patch rather than an artefact of the palette — which is the whole \t\t * reason not to use a rainbow here. \t\t */ \t\tvar VIRIDIS = [ \t\t\t[ 68, 1, 84 ], [ 72, 40, 120 ], [ 62, 74, 137 ], [ 49, 104, 142 ], \t\t\t[ 38, 130, 142 ], [ 31, 158, 137 ], [ 53, 183, 121 ], [ 109, 205, 89 ], \t\t\t[ 180, 222, 44 ], [ 253, 231, 37 ] \t\t]; \t\tfunction viridis( lit ) { \t\t\tvar at = Math.max( 0, Math.min( 1, lit ) ) * ( VIRIDIS.length - 1 ); \t\t\tvar low = Math.floor( at ); \t\t\tvar high = Math.min( VIRIDIS.length - 1, low + 1 ); \t\t\tvar mix = at - low; \t\t\treturn [ \t\t\t\tVIRIDIS[ low ][ 0 ] + ( VIRIDIS[ high ][ 0 ] - VIRIDIS[ low ][ 0 ] ) * mix, \t\t\t\tVIRIDIS[ low ][ 1 ] + ( VIRIDIS[ high ][ 1 ] - VIRIDIS[ low ][ 1 ] ) * mix, \t\t\t\tVIRIDIS[ low ][ 2 ] + ( VIRIDIS[ high ][ 2 ] - VIRIDIS[ low ][ 2 ] ) * mix \t\t\t]; \t\t} \t\tfunction closeSpectro() { \t\t\tspectroModal.hidden = true; \t\t\tspectroBackdrop.hidden = true; \t\t\tspectroModal.classList.remove( "open" ); \t\t\tspectroBackdrop.classList.remove( "open" ); \t\t} \t\tfunction paintSpectro( picture ) { \t\t\tvar ctx = spectroCanvas.getContext( "2d" ); \t\t\tif ( ! ctx ) { \t\t\t\treturn; \t\t\t} \t\t\tspectroCanvas.width = picture.columns; \t\t\tspectroCanvas.height = picture.bins; \t\t\tvar image = ctx.createImageData( picture.columns, picture.bins ); \t\t\tfor ( var x = 0; x < picture.columns; x++ ) { \t\t\t\tfor ( var y = 0; y < picture.bins; y++ ) { \t\t\t\t\tvar cell = picture.cells[ x * picture.bins + y ]; \t\t\t\t\tvar rgb = viridis( cell / 255 ); \t\t\t\t\t// Drawn bottom-up: low frequencies belong at the bottom. \t\t\t\t\tvar at = ( ( picture.bins - 1 - y ) * picture.columns + x ) * 4; \t\t\t\t\timage.data[ at ] = rgb[ 0 ]; \t\t\t\t\timage.data[ at + 1 ] = rgb[ 1 ]; \t\t\t\t\timage.data[ at + 2 ] = rgb[ 2 ]; \t\t\t\t\timage.data[ at + 3 ] = 255; \t\t\t\t} \t\t\t} \t\t\tctx.putImageData( image, 0, 0 ); \t\t\t// The frequency axis, top down: Nyquist at the top of the picture \t\t\t// and DC at the bottom, which is the way round every spectrogram \t\t\t// anybody has ever read is drawn. \t\t\tvar top = picture.sampleRate / 2; \t\t\tspectroScale.textContent = ""; \t\t\t[ 1, 0.75, 0.5, 0.25, 0 ].forEach( function ( part ) { \t\t\t\tvar mark = document.createElement( "span" ); \t\t\t\tvar hz = top * part; \t\t\t\t// Below a kilohertz the number in kHz is all decimal point, so \t\t\t\t// it is written in hertz instead. \t\t\t\tmark.textContent = hz >= 1000 \t\t\t\t\t? ( Math.round( hz / 100 ) / 10 ) + " kHz" \t\t\t\t\t: Math.round( hz ) + " Hz"; \t\t\t\tspectroScale.appendChild( mark ); \t\t\t} ); \t\t} \t\tfunction openSpectro() { \t\t\tvar row = playerRow(); \t\t\tif ( ! row || ! row.file ) { \t\t\t\treturn; \t\t\t} \t\t\tspectroModal.hidden = false; \t\t\tspectroBackdrop.hidden = false; \t\t\tspectroModal.classList.add( "open" ); \t\t\tspectroBackdrop.classList.add( "open" ); \t\t\tspectroTitle.textContent = row.parsed.fileName; \t\t\tspectroNote.textContent = t( "spectroReading" ); \t\t\tvar channels = ( g( row.parsed, "format", "numChannels" ) ) || 1; \t\t\tvar gains = []; \t\t\tfor ( var i = 0; i < channels; i++ ) { \t\t\t\tgains.push( state.playback \t\t\t\t\t? computeEffectiveGain( i, state.playback.muted, state.playback.soloed ) \t\t\t\t\t: 1 ); \t\t\t} \t\t\tplayer( "bwf_spectrogram", { \t\t\t\tpath: row.file.path, \t\t\t\tcolumns: 1200, \t\t\t\twindow: 2048, \t\t\t\tgains: gains \t\t\t} ).then( function ( picture ) { \t\t\t\tpaintSpectro( picture ); \t\t\t\tspectroNote.textContent = Parser.formatDuration( picture.seconds ) + \t\t\t\t\t" \u00b7 up to " + Math.round( picture.sampleRate / 2000 ) + " kHz"; \t\t\t} ).catch( function () { \t\t\t\tspectroNote.textContent = t( "spectroFailed" ); \t\t\t} ); \t\t} \t\tcontainer.addEventListener( "click", function ( e ) { \t\t\tif ( e.target.closest( "[data-bwfa-spectro-open]" ) ) { \t\t\t\topenSpectro(); \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-spectro-save]" ) ) { \t\t\t\t// Down the same road every other export takes: a download link \t\t\t\t// the bridge intercepts and turns into the native save panel, so \t\t\t\t// this asks where rather than deciding. \t\t\t\tvar row = playerRow(); \t\t\t\tvar stem = row && row.parsed \t\t\t\t\t? row.parsed.fileName.replace( /\\.[^.]*$/, "" ) : "spectrogram"; \t\t\t\tspectroCanvas.toBlob( function ( blob ) { \t\t\t\t\tif ( ! blob ) { \t\t\t\t\t\treturn; \t\t\t\t\t} \t\t\t\t\tvar url = URL.createObjectURL( blob ); \t\t\t\t\tvar link = document.createElement( "a" ); \t\t\t\t\tlink.href = url; \t\t\t\t\tlink.download = stem + " spectrogram.png"; \t\t\t\t\tdocument.body.appendChild( link ); \t\t\t\t\tlink.click(); \t\t\t\t\tdocument.body.removeChild( link ); \t\t\t\t}, "image/png" ); \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-spectro-close]" ) || \t\t\t\te.target.closest( "[data-bwfa-spectro-backdrop]" ) ) { \t\t\t\tcloseSpectro(); \t\t\t} \t\t} ); \t\tfunction startTransportLoop() {""", ), ] MIXER_PATCHES = [ # The fader lives inside the gain every playback path already asks for, so # one change here reaches Web Audio and the native engine both. ( """\tfunction computeEffectiveGain( channelIndex, mutedSet, soloedSet ) { \t\tif ( soloedSet && soloedSet.size > 0 ) { \t\t\treturn soloedSet.has( channelIndex ) ? 1 : 0; \t\t} \t\treturn ( mutedSet && mutedSet.has( channelIndex ) ) ? 0 : 1; \t}""", """\tfunction computeEffectiveGain( channelIndex, mutedSet, soloedSet ) { \t\tif ( soloedSet && soloedSet.size > 0 ) { \t\t\treturn soloedSet.has( channelIndex ) ? mixLevelFor( channelIndex ) : 0; \t\t} \t\treturn ( mutedSet && mutedSet.has( channelIndex ) ) \t\t\t? 0 : mixLevelFor( channelIndex ); \t} \t/* ---- the mix --------------------------------------------------- \t Fader positions per file, held as linear gain and multiplied into \t the gain the mute/solo chips already produce. Monitoring only: no \t export, report or written file reads any of this. Kept per file so \t stepping to the next card and back does not throw your mix away. \t ---------------------------------------------------------------- */ \tvar mixLevels = new Map(); \tvar mixKey = null; \t/** Floor of the fader travel. Anything at or below is silence, not -60 dB. */ \tvar MIX_FLOOR_DB = -60; \tvar MIX_CEIL_DB = 6; \tfunction mixLevelFor( channelIndex ) { \t\tvar forFile = mixKey ? mixLevels.get( mixKey ) : null; \t\tif ( ! forFile ) { \t\t\treturn 1; \t\t} \t\tvar level = forFile[ channelIndex ]; \t\treturn ( typeof level === "number" ) ? level : 1; \t} \tfunction mixDbToGain( db ) { \t\tif ( db <= MIX_FLOOR_DB ) { \t\t\treturn 0; \t\t} \t\treturn Math.pow( 10, Math.min( db, MIX_CEIL_DB ) / 20 ); \t} \tfunction mixGainToDb( gain ) { \t\tif ( ! ( gain > 0 ) ) { \t\t\treturn MIX_FLOOR_DB; \t\t} \t\treturn Math.max( MIX_FLOOR_DB, Math.min( MIX_CEIL_DB, 20 * Math.log( gain ) / Math.LN10 ) ); \t} \tfunction setMixKey( key ) { \t\tmixKey = key || null; \t} \tfunction setMixLevel( channelIndex, gain ) { \t\tif ( ! mixKey ) { \t\t\treturn; \t\t} \t\tvar forFile = mixLevels.get( mixKey ); \t\tif ( ! forFile ) { \t\t\tforFile = []; \t\t\tmixLevels.set( mixKey, forFile ); \t\t} \t\tforFile[ channelIndex ] = gain; \t}""", ), ( """\t\tfunction renderChannelChips( row ) {""", """\t\tvar mixerModal = container.querySelector( "[data-bwfa-mixer]" ); \t\tvar mixerBackdrop = container.querySelector( "[data-bwfa-mixer-backdrop]" ); \t\tvar mixerStrips = container.querySelector( "[data-bwfa-mixer-strips]" ); \t\tvar mixerFilename = container.querySelector( "[data-bwfa-mixer-filename]" ); \t\tvar mixerPlayPause = container.querySelector( "[data-bwfa-mixer-playpause]" ); \t\tvar mixerElapsed = container.querySelector( "[data-bwfa-mixer-elapsed]" ); \t\tvar mixerDuration = container.querySelector( "[data-bwfa-mixer-duration]" ); \t\tvar mixerWave = container.querySelector( "[data-bwfa-mixer-waveform]" ); \t\t/** The file the transport is holding. The native build keeps a helper \t\t * for this; the browser build has the same two places to look. */ \t\t/** \t\t * Writes a button's label without replacing the node under the pointer. \t\t * \t\t * The transport's label is refreshed on every status tick, ten times a \t\t * second. Setting textContent destroys and rebuilds the text each time, \t\t * even when the words have not changed, and doing that between someone's \t\t * mouse going down and coming up is how a click gets lost. So: write to a \t\t * span that stays put, and only when the words actually differ. \t\t */ \t\tfunction setButtonLabel( button, text ) { \t\t\tif ( ! button ) { \t\t\t\treturn; \t\t\t} \t\t\tvar label = button.querySelector( "[data-bwfa-label]" ) || button; \t\t\tif ( label.textContent !== text ) { \t\t\t\tlabel.textContent = text; \t\t\t} \t\t} \t\tfunction mixerRow() { \t\t\tif ( state.playerRow ) { \t\t\t\treturn state.playerRow(); \t\t\t} \t\t\treturn ( state.playback && state.playback.row ) || \t\t\t\t( state.lastPlayed && state.lastPlayed.row ) || null; \t\t} \t\tfunction mixerOpen() { \t\t\treturn !! ( mixerModal && ! mixerModal.hidden ); \t\t} \t\t/** Redraws the strips from scratch: called on open and on file change. */ \t\tfunction renderMixer() { \t\t\tif ( ! mixerStrips ) { \t\t\t\treturn; \t\t\t} \t\t\tvar row = mixerRow(); \t\t\tmixerStrips.textContent = ""; \t\t\tif ( mixerFilename ) { \t\t\t\tmixerFilename.textContent = ( row && row.parsed && row.parsed.fileName ) || ""; \t\t\t} \t\t\tif ( ! row ) { \t\t\t\tvar empty = document.createElement( "p" ); \t\t\t\tempty.className = "bwfa-mixer-empty"; \t\t\t\tempty.textContent = t( "mixerNoFile" ); \t\t\t\tmixerStrips.appendChild( empty ); \t\t\t\treturn; \t\t\t} \t\t\tsetMixKey( row._id ); \t\t\tvar channels = g( row.parsed, "format", "numChannels" ) || 0; \t\t\tvar trackList = g( row.parsed, "ixml", "trackList" ) || []; \t\t\tfor ( var i = 0; i < channels; i++ ) { \t\t\t\tvar track = trackList[ i ]; \t\t\t\tvar strip = document.createElement( "div" ); \t\t\t\tstrip.className = "bwfa-mixer-strip"; \t\t\t\tstrip.setAttribute( "data-channel-index", String( i ) ); \t\t\t\tvar name = document.createElement( "span" ); \t\t\t\tname.className = "bwfa-mixer-name"; \t\t\t\tname.textContent = ( track && track.name ) \t\t\t\t\t? track.name : ( t( "channelLabel" ) + " " + ( i + 1 ) ); \t\t\t\tstrip.appendChild( name ); \t\t\t\t// Only where there is something to feed it. A meter that never \t\t\t\t// moves is worse than no meter: it reads as a broken app. \t\t\t\tif ( meteringAvailable() ) { \t\t\t\t\t// The cell is the grid item; the meter fills it and the scale \t\t\t\t\t// sits above it. Anything measuring the same column has to be \t\t\t\t\t// inside the same box, or it only lines up by luck. \t\t\t\t\tvar cell = document.createElement( "span" ); \t\t\t\t\tcell.className = "bwfa-mixer-meter-cell"; \t\t\t\t\tif ( i === 0 ) { \t\t\t\t\t\tcell.appendChild( meterScale() ); \t\t\t\t\t} \t\t\t\t\tvar meter = document.createElement( "span" ); \t\t\t\t\tmeter.className = "bwfa-mixer-meter"; \t\t\t\t\tmeter.setAttribute( "data-bwfa-mixer-meter", String( i ) ); \t\t\t\t\t// The gradient lives on the track and a mask hides what is not \t\t\t\t\t// lit, so -6 dB is the same amber whatever else is happening. \t\t\t\t\t// A bar that recolours itself as it rises tells you nothing. \t\t\t\t\tvar mask = document.createElement( "span" ); \t\t\t\t\tmask.className = "bwfa-mixer-meter-mask"; \t\t\t\t\tmeter.appendChild( mask ); \t\t\t\t\tvar hold = document.createElement( "span" ); \t\t\t\t\thold.className = "bwfa-mixer-meter-hold"; \t\t\t\t\tmeter.appendChild( hold ); \t\t\t\t\tcell.appendChild( meter ); \t\t\t\t\tstrip.appendChild( cell ); \t\t\t\t} \t\t\t\tvar fader = document.createElement( "input" ); \t\t\t\tfader.type = "range"; \t\t\t\tfader.className = "bwfa-mixer-fader"; \t\t\t\tfader.min = String( MIX_FLOOR_DB ); \t\t\t\tfader.max = String( MIX_CEIL_DB ); \t\t\t\tfader.step = "0.5"; \t\t\t\tfader.value = String( mixGainToDb( mixLevelFor( i ) ) ); \t\t\t\tfader.setAttribute( "data-bwfa-mixer-fader", String( i ) ); \t\t\t\tfader.setAttribute( "aria-label", name.textContent + " level" ); \t\t\t\tstrip.appendChild( fader ); \t\t\t\tvar readout = document.createElement( "span" ); \t\t\t\treadout.className = "bwfa-mixer-db"; \t\t\t\treadout.setAttribute( "data-bwfa-mixer-db", String( i ) ); \t\t\t\treadout.textContent = mixerDbText( parseFloat( fader.value ) ); \t\t\t\tstrip.appendChild( readout ); \t\t\t\tvar mute = document.createElement( "button" ); \t\t\t\tmute.type = "button"; \t\t\t\tmute.className = "chip bwfa-mixer-mute"; \t\t\t\tmute.textContent = t( "mixerMute" ); \t\t\t\tmute.setAttribute( "data-bwfa-mixer-mute", String( i ) ); \t\t\t\tstrip.appendChild( mute ); \t\t\t\tvar solo = document.createElement( "button" ); \t\t\t\tsolo.type = "button"; \t\t\t\tsolo.className = "chip bwfa-mixer-solo"; \t\t\t\tsolo.textContent = t( "mixerSolo" ); \t\t\t\tsolo.setAttribute( "data-bwfa-mixer-solo", String( i ) ); \t\t\t\tstrip.appendChild( solo ); \t\t\t\tmixerStrips.appendChild( strip ); \t\t\t} \t\t\tsyncMixerStates(); \t\t\tsyncMixerTransport(); \t\t} \t\t/* ---- meter ballistics ------------------------------------------- \t\t The engine reports a peak per channel about ten times a second. \t\t Drawn raw that reads as a flicker, so: rise instantly to any new \t\t peak, fall on a time constant, and hold the highest recent peak as \t\t a separate mark. Decay is computed from elapsed time rather than \t\t per call, because this is driven by animation frames and the frame \t\t rate is not ours to assume. \t\t ------------------------------------------------------------------ */ \t\tvar METER_FLOOR_DB = -60; \t\tvar METER_FALL_MS = 380; \t\tvar METER_HOLD_MS = 1400; \t\tvar meterShown = []; \t\tvar meterHeld = []; \t\tvar meterHeldAt = []; \t\tvar meterLast = 0; \t\tvar sawLevels = false; \t\t/** \t\t * Whether this build has an engine that reports levels at all. \t\t * \t\t * Latched rather than asked fresh: stepping to the next file tears down \t\t * state.playback and builds a new one, and for the moment before the \t\t * first status arrives there are no levels on it. Reading that moment as \t\t * "this build has no meters" is what made them vanish on file change. \t\t */ \t\tfunction meteringAvailable() { \t\t\tif ( state.playback && state.playback.levels ) { \t\t\t\tsawLevels = true; \t\t\t} \t\t\treturn sawLevels; \t\t} \t\t/** Linear magnitude to a 0..1 position on a dB scale. */ \t\tfunction meterPosition( magnitude ) { \t\t\tif ( ! ( magnitude > 0 ) ) { \t\t\t\treturn 0; \t\t\t} \t\t\tvar db = 20 * Math.log( magnitude ) / Math.LN10; \t\t\tif ( db <= METER_FLOOR_DB ) { \t\t\t\treturn 0; \t\t\t} \t\t\treturn Math.min( 1, ( db - METER_FLOOR_DB ) / -METER_FLOOR_DB ); \t\t} \t\tfunction updateMeters() { \t\t\t// meteringAvailable() says this build has meters, not that there is \t\t\t// something playing right now — between two files there isn't. \t\t\tif ( ! mixerStrips || ! meteringAvailable() || ! state.playback ) { \t\t\t\treturn; \t\t\t} \t\t\tvar now = ( window.performance && window.performance.now ) \t\t\t\t? window.performance.now() : Date.now(); \t\t\tvar since = meterLast ? Math.max( 0, now - meterLast ) : 0; \t\t\tmeterLast = now; \t\t\tvar fall = Math.exp( -since / METER_FALL_MS ); \t\t\tvar levels = state.playback.levels || []; \t\t\tvar playing = !! state.playback.isPlaying; \t\t\tvar nodes = mixerStrips.querySelectorAll( "[data-bwfa-mixer-meter]" ); \t\t\tArray.prototype.forEach.call( nodes, function ( node ) { \t\t\t\tvar index = parseInt( node.getAttribute( "data-bwfa-mixer-meter" ), 10 ); \t\t\t\tvar arrived = playing ? ( levels[ index ] || 0 ) : 0; \t\t\t\tvar shown = meterShown[ index ] || 0; \t\t\t\tshown = arrived > shown ? arrived : shown * fall; \t\t\t\tmeterShown[ index ] = shown; \t\t\t\tif ( arrived >= ( meterHeld[ index ] || 0 ) || \t\t\t\t\tnow - ( meterHeldAt[ index ] || 0 ) > METER_HOLD_MS ) { \t\t\t\t\tmeterHeld[ index ] = arrived; \t\t\t\t\tmeterHeldAt[ index ] = now; \t\t\t\t} \t\t\t\tvar maskNode = node.querySelector( ".bwfa-mixer-meter-mask" ); \t\t\t\tvar holdNode = node.querySelector( ".bwfa-mixer-meter-hold" ); \t\t\t\tif ( maskNode ) { \t\t\t\t\tmaskNode.style.left = ( meterPosition( shown ) * 100 ).toFixed( 1 ) + "%"; \t\t\t\t} \t\t\t\t// Full scale latches on the peak mark: by the time you look, the \t\t\t\t// bar itself has already fallen back. \t\t\t\tnode.classList.toggle( "is-hot", ( meterHeld[ index ] || 0 ) >= 0.997 ); \t\t\t\tif ( holdNode ) { \t\t\t\t\tvar held = meterPosition( meterHeld[ index ] || 0 ); \t\t\t\t\tholdNode.style.left = ( held * 100 ).toFixed( 1 ) + "%"; \t\t\t\t\tholdNode.style.opacity = held > 0 ? "1" : "0"; \t\t\t\t} \t\t\t\tnode.setAttribute( "data-bwfa-meter-db", shown > 0 \t\t\t\t\t? ( 20 * Math.log( shown ) / Math.LN10 ).toFixed( 1 ) : "-inf" ); \t\t\t} ); \t\t} \t\t/** \t\t * The scale, built on the same grid as the strips so its marks land over \t\t * the meter column rather than near it. \t\t */ \t\tfunction meterScale() { \t\t\tvar scale = document.createElement( "span" ); \t\t\tscale.className = "bwfa-mixer-scale"; \t\t\tscale.setAttribute( "data-bwfa-mixer-scale", "" ); \t\t\t[ -60, -40, -20, -6, 0 ].forEach( function ( db ) { \t\t\t\tvar mark = document.createElement( "span" ); \t\t\t\tmark.textContent = db === 0 ? "0" : String( db ); \t\t\t\tmark.style.left = ( ( ( db - METER_FLOOR_DB ) / -METER_FLOOR_DB ) * 100 ) \t\t\t\t\t.toFixed( 1 ) + "%"; \t\t\t\t// The end marks would hang off the bar if they were centred. \t\t\t\tif ( db === METER_FLOOR_DB ) { \t\t\t\t\tmark.style.transform = "none"; \t\t\t\t} else if ( db === 0 ) { \t\t\t\t\tmark.style.transform = "translateX(-100%)"; \t\t\t\t} \t\t\t\tscale.appendChild( mark ); \t\t\t} ); \t\t\treturn scale; \t\t} \t\tfunction mixerDbText( db ) { \t\t\tif ( db <= MIX_FLOOR_DB ) { \t\t\t\treturn "\u2212\u221e"; \t\t\t} \t\t\tvar rounded = Math.round( db * 10 ) / 10; \t\t\treturn ( rounded > 0 ? "+" : "" ) + rounded.toFixed( 1 ) + " dB"; \t\t} \t\t/** Mute and solo are the same state the player chips show, not a copy. */ \t\tfunction syncMixerStates() { \t\t\tif ( ! mixerStrips ) { \t\t\t\treturn; \t\t\t} \t\t\tvar muted = ( state.playback && state.playback.muted ) || new Set(); \t\t\tvar soloed = ( state.playback && state.playback.soloed ) || new Set(); \t\t\tArray.prototype.forEach.call( \t\t\t\tmixerStrips.querySelectorAll( "[data-bwfa-mixer-mute]" ), \t\t\t\tfunction ( button ) { \t\t\t\t\tvar index = parseInt( button.getAttribute( "data-bwfa-mixer-mute" ), 10 ); \t\t\t\t\tbutton.classList.toggle( "is-muted", muted.has( index ) ); \t\t\t\t} \t\t\t); \t\t\tArray.prototype.forEach.call( \t\t\t\tmixerStrips.querySelectorAll( "[data-bwfa-mixer-solo]" ), \t\t\t\tfunction ( button ) { \t\t\t\t\tvar index = parseInt( button.getAttribute( "data-bwfa-mixer-solo" ), 10 ); \t\t\t\t\tbutton.classList.toggle( "is-soloed", soloed.has( index ) ); \t\t\t\t} \t\t\t); \t\t} \t\t/** Clock, scrubber and the Play/Pause word, off the one transport. */ \t\tfunction syncMixerTransport() { \t\t\tif ( ! mixerOpen() ) { \t\t\t\treturn; \t\t\t} \t\t\tvar playing = !! ( state.playback && state.playback.isPlaying ); \t\t\tif ( mixerPlayPause ) { \t\t\t\tsetButtonLabel( mixerPlayPause, playing ? t( "pause" ) : t( "play" ) ); \t\t\t\tmixerPlayPause.disabled = ! state.playback; \t\t\t} \t\t\tvar duration = ( state.playback && state.playback.buffer ) \t\t\t\t? state.playback.buffer.duration : 0; \t\t\tvar elapsed = state.playback ? Math.min( getElapsed(), duration ) : 0; \t\t\tif ( mixerElapsed ) { \t\t\t\tmixerElapsed.textContent = Parser.formatDuration( elapsed ); \t\t\t} \t\t\tif ( mixerDuration ) { \t\t\t\tmixerDuration.textContent = Parser.formatDuration( duration ); \t\t\t} \t\t\t// The same waveform, drawn by the same code as the player's, so the \t\t\t// two cannot drift apart in appearance or in what they mean. \t\t\tif ( mixerWave && state.playback && state.playback.peaks && duration > 0 ) { \t\t\t\tvar ctx = mixerWave.getContext( "2d" ); \t\t\t\tif ( ctx ) { \t\t\t\t\tvar colors = getWaveformColors( mixerWave ); \t\t\t\t\tdrawWaveformOnCanvas( \t\t\t\t\t\tctx, mixerWave.width, mixerWave.height, \t\t\t\t\t\tstate.playback.peaks, elapsed / duration, \t\t\t\t\t\tcolors.wave, colors.playhead \t\t\t\t\t); \t\t\t\t} \t\t\t} \t\t\tupdateMeters(); \t\t\tupdateMixerNav(); \t\t} \t\tfunction mixerIndex() { \t\t\tvar row = mixerRow(); \t\t\tif ( ! row ) { \t\t\t\treturn -1; \t\t\t} \t\t\treturn state.filteredRows.findIndex( function ( r ) { \t\t\t\treturn r._id === row._id; \t\t\t} ); \t\t} \t\tfunction updateMixerNav() { \t\t\tvar index = mixerIndex(); \t\t\tvar prev = container.querySelector( "[data-bwfa-mixer-prev]" ); \t\t\tvar next = container.querySelector( "[data-bwfa-mixer-next]" ); \t\t\tvar prevName = container.querySelector( "[data-bwfa-mixer-prev-name]" ); \t\t\tvar nextName = container.querySelector( "[data-bwfa-mixer-next-name]" ); \t\t\tvar nameAt = function ( at ) { \t\t\t\tvar row = state.filteredRows[ at ]; \t\t\t\treturn ( row && row.parsed && row.parsed.fileName ) || ""; \t\t\t}; \t\t\tvar hasPrev = index > 0; \t\t\tvar hasNext = index !== -1 && index < state.filteredRows.length - 1; \t\t\tif ( prev ) { \t\t\t\tprev.disabled = ! hasPrev; \t\t\t} \t\t\tif ( next ) { \t\t\t\tnext.disabled = ! hasNext; \t\t\t} \t\t\tif ( prevName ) { \t\t\t\tprevName.textContent = hasPrev ? nameAt( index - 1 ) : ""; \t\t\t} \t\t\tif ( nextName ) { \t\t\t\tnextName.textContent = hasNext ? nameAt( index + 1 ) : ""; \t\t\t} \t\t} \t\tfunction navigateMixer( delta ) { \t\t\tvar index = mixerIndex(); \t\t\tif ( index === -1 ) { \t\t\t\treturn; \t\t\t} \t\t\tvar target = state.filteredRows[ index + delta ]; \t\t\tif ( ! target ) { \t\t\t\treturn; \t\t\t} \t\t\t// Same call the table row makes, so the player and the mixer can \t\t\t// never end up looking at two different files. \t\t\tplayRow( target ); \t\t\trenderMixer(); \t\t} \t\tfunction openMixer() { \t\t\tif ( ! mixerModal ) { \t\t\t\t// A dead button with no explanation is the worst outcome here, so \t\t\t\t// say so rather than returning quietly. \t\t\t\tconsole.error( "BWF Analyser: the mixer markup is missing from this page" + \t\t\t\t\t" (page built " + ( window.BWFA_BUILD || "unknown" ) + ")" ); \t\t\t\treturn; \t\t\t} \t\t\tmixerModal.hidden = false; \t\t\t// The class, not just the attribute. Unhiding a .modal is not enough \t\t\t// to show it in this framework — without .open it stays at \t\t\t// display:none, which looks precisely like a dead button. \t\t\tmixerModal.classList.add( "open" ); \t\t\tif ( mixerBackdrop ) { \t\t\t\tmixerBackdrop.hidden = false; \t\t\t\tmixerBackdrop.classList.add( "open" ); \t\t\t} \t\t\trenderMixer(); \t\t} \t\tfunction closeMixer() { \t\t\tif ( ! mixerModal ) { \t\t\t\treturn; \t\t\t} \t\t\tmixerModal.hidden = true; \t\t\tmixerModal.classList.remove( "open" ); \t\t\tif ( mixerBackdrop ) { \t\t\t\tmixerBackdrop.hidden = true; \t\t\t\tmixerBackdrop.classList.remove( "open" ); \t\t\t} \t\t} \t\tcontainer.addEventListener( "click", function ( e ) { \t\t\tif ( e.target.closest( "[data-bwfa-mixer-open]" ) ) { \t\t\t\topenMixer(); \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-mixer-close]" ) || \t\t\t\te.target.closest( "[data-bwfa-mixer-backdrop]" ) ) { \t\t\t\tcloseMixer(); \t\t\t\treturn; \t\t\t} \t\t\tif ( ! mixerOpen() ) { \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-mixer-playpause]" ) ) { \t\t\t\ttogglePlayPause(); \t\t\t\tsyncMixerTransport(); \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-mixer-prev]" ) ) { \t\t\t\tnavigateMixer( -1 ); \t\t\t\treturn; \t\t\t} \t\t\tif ( e.target.closest( "[data-bwfa-mixer-next]" ) ) { \t\t\t\tnavigateMixer( 1 ); \t\t\t\treturn; \t\t\t} \t\t\tvar muteBtn = e.target.closest( "[data-bwfa-mixer-mute]" ); \t\t\tif ( muteBtn ) { \t\t\t\ttoggleMute( parseInt( muteBtn.getAttribute( "data-bwfa-mixer-mute" ), 10 ) ); \t\t\t\tsyncMixerStates(); \t\t\t\treturn; \t\t\t} \t\t\tvar soloBtn = e.target.closest( "[data-bwfa-mixer-solo]" ); \t\t\tif ( soloBtn ) { \t\t\t\ttoggleSolo( parseInt( soloBtn.getAttribute( "data-bwfa-mixer-solo" ), 10 ) ); \t\t\t\tsyncMixerStates(); \t\t\t} \t\t} ); \t\tif ( mixerWave ) { \t\t\tmixerWave.addEventListener( "click", function ( e ) { \t\t\t\tif ( ! state.playback ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tvar rect = mixerWave.getBoundingClientRect(); \t\t\t\tvar fraction = ( e.clientX - rect.left ) / rect.width; \t\t\t\tseekTo( fraction * state.playback.buffer.duration ); \t\t\t} ); \t\t} \t\tcontainer.addEventListener( "input", function ( e ) { \t\t\tvar fader = e.target.closest && e.target.closest( "[data-bwfa-mixer-fader]" ); \t\t\tif ( fader ) { \t\t\t\tvar index = parseInt( fader.getAttribute( "data-bwfa-mixer-fader" ), 10 ); \t\t\t\tvar db = parseFloat( fader.value ); \t\t\t\tsetMixLevel( index, mixDbToGain( db ) ); \t\t\t\tvar readout = mixerStrips.querySelector( \t\t\t\t\t'[data-bwfa-mixer-db="' + index + '"]' ); \t\t\t\tif ( readout ) { \t\t\t\t\treadout.textContent = mixerDbText( db ); \t\t\t\t} \t\t\t\tapplyChannelGains(); \t\t\t} \t\t} ); \t\tfunction renderChannelChips( row ) {""", ), # The clock ticks off the one transport loop the player already runs. ( """\t\t\t\t\tcolors.wave, colors.playhead \t\t\t\t); \t\t\t} \t\t}""", """\t\t\t\t\tcolors.wave, colors.playhead \t\t\t\t); \t\t\t} \t\t\tsyncMixerTransport(); \t\t}""", ), ( """\t\tfunction syncPlaybackUI() {""", """\t\tfunction syncPlaybackUI() { \t\t\tsyncMixerTransport(); \t\t\tsyncMixerStates();""", ), ] PLAYER_PATCHES = [ ( """\t\tfunction decodeFileToAudioBuffer( row ) { \t\t\tvar cached = state.decodedBufferCache.get( row._id ); \t\t\tif ( cached ) { \t\t\t\treturn Promise.resolve( cached ); \t\t\t} \t\t\treturn row.file.arrayBuffer().then( function ( arrayBuffer ) { \t\t\t\treturn ensureAudioContext().decodeAudioData( arrayBuffer ); \t\t\t} ).then( function ( buffer ) { \t\t\t\tstate.decodedBufferCache.set( row._id, buffer ); \t\t\t\treturn buffer; \t\t\t} ); \t\t}""", """\t\tfunction player( command, args ) { \t\t\tvar tauri = window.__TAURI__; \t\t\tif ( ! tauri || ! tauri.core ) { \t\t\t\treturn Promise.reject( new Error( "the player is not available" ) ); \t\t\t} \t\t\treturn tauri.core.invoke( command, args || {} ); \t\t} \t\t/** \t\t * What the player needs about a file, without reading the audio into \t\t * memory: how long it is, how many channels it has, and the waveform \t\t * already bucketed into columns. Stands in for the AudioBuffer the rest \t\t * of the player was written against. \t\t */ \t\tfunction decodeFileToAudioBuffer( row ) { \t\t\tvar cached = state.decodedBufferCache.get( row._id ); \t\t\tif ( cached ) { \t\t\t\treturn Promise.resolve( cached ); \t\t\t} \t\t\treturn player( "bwf_peaks", { \t\t\t\tpath: row.file.path, \t\t\t\tbuckets: WAVEFORM_PEAK_RESOLUTION \t\t\t} ).then( function ( read ) { \t\t\t\tvar buffer = { \t\t\t\t\tduration: read.seconds, \t\t\t\t\tnumberOfChannels: read.channels, \t\t\t\t\tsampleRate: read.sampleRate, \t\t\t\t\tpeaks: { \t\t\t\t\t\tmin: Float32Array.from( read.min || [] ), \t\t\t\t\t\tmax: Float32Array.from( read.max || [] ) \t\t\t\t\t} \t\t\t\t}; \t\t\t\tstate.decodedBufferCache.set( row._id, buffer ); \t\t\t\treturn buffer; \t\t\t} ); \t\t}""", ), # There is no audio graph any more. These keep the shape the transport, # the chips and the seek logic were written against, so none of them has # to learn where the sound now comes from. ( """\t\t\tvar context = ensureAudioContext(); \t\t\tvar numChannels = buffer.numberOfChannels; \t\t\tvar source = context.createBufferSource(); \t\t\tsource.buffer = buffer; \t\t\tsource._expectingStop = false;""", """\t\t\t// No context, no graph, nothing in the page that can make a \t\t\t// sound. These stand in for the nodes the transport, the chips and \t\t\t// the seek logic were written against, so none of them has to learn \t\t\t// where the audio now comes from. \t\t\treturn { \t\t\t\tsourceNode: { \t\t\t\t\tstart: function () {}, \t\t\t\t\tstop: function () {}, \t\t\t\t\tdisconnect: function () {} \t\t\t\t}, \t\t\t\tsplitter: null, \t\t\t\tgains: [], \t\t\t\tsumNode: null \t\t\t};""", ), # Mute and solo become one gain per source channel, applied where the # summing happens. ( """\t\t\tstate.playback.gains.forEach( function ( gainNode, i ) { \t\t\t\tgainNode.gain.value = computeEffectiveGain( i, state.playback.muted, state.playback.soloed ); \t\t\t} );""", """\t\t\tvar channels = ( state.playback.buffer || {} ).numberOfChannels || 0; \t\t\tvar levels = []; \t\t\tfor ( var i = 0; i < channels; i++ ) { \t\t\t\tlevels.push( computeEffectiveGain( i, state.playback.muted, state.playback.soloed ) ); \t\t\t} \t\t\tstate.playback.channelGains = levels; \t\t\tplayer( "bwf_gains", { gains: levels } ).catch( function () {} );""", ), # Nothing stamps a start time off a context clock any more, because there # is no context: the engine reports where it actually is. ( """\t\t\t\t\tstartedAt: state.audioContext.currentTime,""", """\t\t\t\t\tstartedAt: 0,""", ), ( """\t\t\tstate.playback.startedAt = state.audioContext.currentTime;""", """\t\t\tstate.playback.startedAt = 0;""", ), # The clock is the one the output device is actually keeping, reported # back, rather than a context clock that keeps counting when nothing is # being heard. ( """\t\t\tif ( state.playback.isPlaying ) { \t\t\t\treturn state.playback.offset + ( state.audioContext.currentTime - state.playback.startedAt ); \t\t\t} \t\t\treturn state.playback.offset;""", """\t\t\tif ( state.playback.isPlaying ) { \t\t\t\treturn state.playback.elapsed === undefined \t\t\t\t\t? state.playback.offset : state.playback.elapsed; \t\t\t} \t\t\treturn state.playback.offset;""", ), # Starting, stopping and seeking are all one call: the engine opens the # file at an offset, so a seek is a fresh start rather than a graph rebuilt # around a buffer. ( """\t\t\tensureAudioContext(); \t\t\tgraph.sourceNode.start( 0, state.playback.offset ); \t\t\twatchAudioContext(); \t\t\tstartTransportLoop();""", """\t\t\tstate.playback.elapsed = state.playback.offset; \t\t\tplayer( "bwf_play", { \t\t\t\tpath: state.playback.row.file.path, \t\t\t\toffset: state.playback.offset, \t\t\t\tgains: state.playback.channelGains || [] \t\t\t} ).catch( function () { handlePlaybackError(); } ); \t\t\tstartTransportLoop();""", ), ( """\t\t\t\tgraph.sourceNode.start( 0, startAt ); \t\t\t\twatchAudioContext();""", """\t\t\tstate.playback.elapsed = 0; \t\t\t\tplayer( "bwf_play", { \t\t\t\t\tpath: row.file.path, \t\t\t\t\toffset: startAt, \t\t\t\t\tgains: state.playback.channelGains || [] \t\t\t\t} ).catch( function () { handlePlaybackError(); } );""", ), ( """\t\t\tstate.playback.offset = getElapsed(); \t\t\tstopNode( state.playback );""", """\t\t\tstate.playback.offset = getElapsed(); \t\t\tplayer( "bwf_pause" ).catch( function () {} ); \t\t\tstopNode( state.playback );""", ), ( """\t\tfunction stopPlayback() { \t\t\tif ( state.playback ) {""", """\t\tfunction stopPlayback() { \t\t\tplayer( "bwf_stop" ).catch( function () {} ); \t\t\tif ( state.playback ) {""", ), # The engine reports where it is, whether it is still going, and when it # had to reopen the output underneath a playing file. ( """\t\tfunction startTransportLoop() {""", """\t\t/** \t\t * Everything the engine has to say: the position it is actually at, the \t\t * end of a file, and the fact that it rebuilt the output because the \t\t * device changed or the stream faulted. That last one used to be the \t\t * unrecoverable case; now it is a line in the status bar. \t\t */ \t\tif ( window.__TAURI__ && window.__TAURI__.event ) { \t\t\twindow.__TAURI__.event.listen( "bwf://playback", function ( message ) { \t\t\t\tvar report = ( message && message.payload ) || {}; \t\t\t\tif ( report.error ) { \t\t\t\t\thandlePlaybackError(); \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tif ( ! state.playback ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tif ( report.reopened ) { \t\t\t\t\tsetStatus( t( "audioReopened" ) ); \t\t\t\t} \t\t\t\tif ( typeof report.seconds === "number" ) { \t\t\t\t\tstate.playback.elapsed = report.seconds; \t\t\t\t} \t\t\t\tif ( report.levels && report.levels.length ) { \t\t\t\t\t// Peak per channel since the last report, straight from the \t\t\t\t\t// audio callback. The page has no samples of its own on this \t\t\t\t\t// build, so this is the only honest source for a meter. \t\t\t\t\tstate.playback.levels = report.levels; \t\t\t\t} \t\t\t\tif ( report.ended ) { \t\t\t\t\tvar finished = state.playback.row; \t\t\t\t\tstopPlayback(); \t\t\t\t\tafterFileFinished( finished ); \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tupdateTransportDisplay(); \t\t\t} ); \t\t} \t\tfunction startTransportLoop() {""", ), # The thumbnails in the table go the same way as the player's waveform: # what came back with the header, not something measured off a decoded # buffer that no longer exists. Missing this one is why the big waveform # drew and the little ones didn't. ( """\t\t\tdecodeFileToAudioBuffer( row ).then( function ( buffer ) { \t\t\t\tvar channelData = []; \t\t\t\tfor ( var i = 0; i < buffer.numberOfChannels; i++ ) { \t\t\t\t\tchannelData.push( buffer.getChannelData( i ) ); \t\t\t\t} \t\t\t\tvar peaks = computeWaveformPeaks( channelData, WAVEFORM_PEAK_RESOLUTION ); \t\t\t\tstate.waveformPeaksCache.set( rowId, peaks );""", """\t\t\tdecodeFileToAudioBuffer( row ).then( function ( buffer ) { \t\t\t\tvar peaks = buffer.peaks; \t\t\t\tstate.waveformPeaksCache.set( rowId, peaks );""", ), # Nothing decodes into memory any more, so the waveform is what came back # with the header rather than something measured off a buffer. ( """\t\t\t\tvar channelData = []; \t\t\t\tfor ( var i = 0; i < buffer.numberOfChannels; i++ ) { \t\t\t\t\tchannelData.push( buffer.getChannelData( i ) ); \t\t\t\t} \t\t\t\tvar peaks = state.waveformPeaksCache.get( row._id ) || computeWaveformPeaks( channelData, WAVEFORM_PEAK_RESOLUTION );""", """\t\t\t\tvar peaks = state.waveformPeaksCache.get( row._id ) || buffer.peaks;""", ), ] APP_JS_PATCHES = [ # ---- the confirmation has to be visible to be a confirmation -------- # # This was window.confirm, which in this webview returns false without # ever showing anything: the first click on Apply did nothing at all and # the second one ran the job. A confirmation before rewriting a folder is # worth having, so it stays — but as part of the panel, where it can be # seen, rather than a system dialog over a modal that never arrives. ( """\t\t\tif ( ! window.confirm( format( t( "bulkEditConfirm" ), targets.length ) ) ) { \t\t\t\treturn; \t\t\t}""", """\t\t\tif ( ! state.bulkConfirmed ) { \t\t\t\tstate.bulkConfirmed = true; \t\t\t\tbulkEditApplyBtn.textContent = \t\t\t\t\tformat( t( "bulkEditConfirmBtn" ), targets.length ); \t\t\t\tbulkEditApplyBtn.classList.add( "is-confirming" ); \t\t\t\tbulkEditProgressEl.textContent = \t\t\t\t\tformat( t( "bulkEditConfirmLine" ), targets.length ); \t\t\t\tbulkEditProgressEl.classList.add( "is-asking" ); \t\t\t\treturn; \t\t\t} \t\t\tstate.bulkConfirmed = false; \t\t\tbulkEditApplyBtn.classList.remove( "is-confirming" ); \t\t\tbulkEditProgressEl.classList.remove( "is-asking" );""", ), # Touching a field is a change of mind about what is being applied, so the # confirmation goes back to being unasked. ( """\t\tfunction updateBulkApplyState() { \t\t\tvar toggles = state.bulkEditToggles || {};""", """\t\tfunction updateBulkApplyState() { \t\t\tif ( state.bulkConfirmed ) { \t\t\t\tstate.bulkConfirmed = false; \t\t\t\tbulkEditApplyBtn.classList.remove( "is-confirming" ); \t\t\t\tbulkEditProgressEl.textContent = ""; \t\t\t\tbulkEditProgressEl.classList.remove( "is-asking" ); \t\t\t} \t\t\tvar toggles = state.bulkEditToggles || {};""", ), # Details moves from the last column to immediately after Play, so the # two row actions sit together instead of at opposite ends of the table. ( """\t\t\t\tvar detailsTd = document.createElement( "td" ); \t\t\t\tvar detailsBtn = document.createElement( "button" ); \t\t\t\tdetailsBtn.type = "button"; \t\t\t\tdetailsBtn.className = "btn btn-sm btn-outline"; \t\t\t\tdetailsBtn.textContent = t( "detailsBtn" ); \t\t\t\tdetailsBtn.addEventListener( "click", function () { openModal( row, detailsBtn ); } ); \t\t\t\tdetailsTd.appendChild( detailsBtn ); \t\t\t\ttr.appendChild( detailsTd ); \t\t\t\tfragment.appendChild( tr );""", """\t\t\t\tfragment.appendChild( tr );""", ), ( """\t\t\t\ttr.appendChild( playTd ); \t\t\t\tcolumns.forEach( function ( col ) {""", """\t\t\t\ttr.appendChild( playTd ); \t\t\t\tvar detailsTd = document.createElement( "td" ); \t\t\t\tvar detailsBtn = document.createElement( "button" ); \t\t\t\tdetailsBtn.type = "button"; \t\t\t\tdetailsBtn.className = "btn btn-sm btn-outline"; \t\t\t\tdetailsBtn.textContent = t( "detailsBtn" ); \t\t\t\tdetailsBtn.addEventListener( "click", function () { openModal( row, detailsBtn ); } ); \t\t\t\tdetailsTd.appendChild( detailsBtn ); \t\t\t\ttr.appendChild( detailsTd ); \t\t\t\tcolumns.forEach( function ( col ) {""", ), # ...and the header cell follows it. ( """\t\t\ttr.appendChild( document.createElement( "th" ) ); // Details column \t\t\ttableHead.appendChild( tr );""", """\t\t\ttableHead.appendChild( tr );""", ), # The shared patches already replaced the Play header cell with one that # carries the columns cog, so this hangs the Details header off that. ( """\t\t\ttr.appendChild( actionsTh ); // Play column, and the columns cog""", """\t\t\ttr.appendChild( actionsTh ); // Play column, and the columns cog \t\t\ttr.appendChild( document.createElement( "th" ) ); // Details column""", ), # Bulk edit: the per-field checkboxes are gone. What a field contains is # now what says whether it gets written — type something and it applies, # leave it blank and that field is left alone on every file. The two # boolean fields become three-way selects, because a checkbox has no # "blank" state to mean "don't touch this". ( """\t\t\t\tvar toggle = document.createElement( "input" ); \t\t\t\ttoggle.type = "checkbox"; \t\t\t\ttoggle.className = "bwfa-bulk-edit-toggle"; \t\t\t\tvar label""", """\t\t\t\tvar label""", ), ( """\t\t\t\tvar input; \t\t\t\tif ( field.type === "checkbox" ) { \t\t\t\t\tinput = document.createElement( "input" ); \t\t\t\t\tinput.type = "checkbox"; \t\t\t\t} else if ( field.type === "select" ) {""", """\t\t\t\tvar input; \t\t\t\tif ( field.type === "checkbox" ) { \t\t\t\t\tinput = document.createElement( "select" ); \t\t\t\t\tinput.className = "form-select"; \t\t\t\t\t[ [ "", t( "noChange" ) ], [ "TRUE", t( "yes" ) ], [ "FALSE", t( "no" ) ] ].forEach( function ( pair ) { \t\t\t\t\t\tvar opt = document.createElement( "option" ); \t\t\t\t\t\topt.value = pair[ 0 ]; \t\t\t\t\t\topt.textContent = pair[ 1 ]; \t\t\t\t\t\tinput.appendChild( opt ); \t\t\t\t\t} ); \t\t\t\t} else if ( field.type === "select" ) {""", ), # The plugin's own select already carries a "(no change)" first option, so # it needs nothing here beyond not being disabled. ( """\t\t\t\tinput.disabled = true; \t\t\t\ttoggle.addEventListener( "change", function () { \t\t\t\t\tinput.disabled = ! toggle.checked; \t\t\t\t\tupdateBulkApplyState(); \t\t\t\t} ); \t\t\t\trow.appendChild( toggle ); \t\t\t\trow.appendChild( label );""", """\t\t\t\tinput.addEventListener( "input", updateBulkApplyState ); \t\t\t\tinput.addEventListener( "change", updateBulkApplyState ); \t\t\t\trow.appendChild( label );""", ), ( """\t\t\t\tinputs[ field.key ] = input; \t\t\t\ttoggles[ field.key ] = toggle;""", """\t\t\t\tinputs[ field.key ] = input;""", ), # The bulk selects had no empty option — they always carried a value, so # under the new rule they would apply on every run. Give them the same # "(no change)" first entry the per-file form already uses. ( """\t\t\t\t} else if ( field.type === "select" ) { \t\t\t\t\tinput = document.createElement( "select" ); \t\t\t\t\tinput.className = "form-select"; \t\t\t\t\tfield.options.forEach( function ( optValue ) {""", """\t\t\t\t} else if ( field.type === "select" ) { \t\t\t\t\tinput = document.createElement( "select" ); \t\t\t\t\tinput.className = "form-select"; \t\t\t\t\tvar bulkNoChange = document.createElement( "option" ); \t\t\t\t\tbulkNoChange.value = ""; \t\t\t\t\tbulkNoChange.textContent = t( "noChange" ); \t\t\t\t\tinput.appendChild( bulkNoChange ); \t\t\t\t\tfield.options.forEach( function ( optValue ) {""", ), # Nothing tracks toggles any more. ( """\t\t\tvar inputs = {}; \t\t\tvar toggles = {};""", """\t\t\tvar inputs = {};""", ), # The export panel is part of the bridge, outside this closure, and it needs # two things from in here: which rows are on screen, and which row the # detail modal is showing. One reference out is less invasive than moving # the panel in. ( """\t\t\teditable: false \t\t};""", """\t\t\teditable: false \t\t}; \t\t// Read by the native export panel. Nothing writes to it from outside. \t\twindow.BWFA_STATE = state;""", ), ( """\t\t\tstate.bulkEditInputs = inputs; \t\t\tstate.bulkEditToggles = toggles;""", """\t\t\tstate.bulkEditInputs = inputs;""", ), # "Has this field been filled in?" replaces "is this field ticked?", in # both the Apply button's enabled state and the edit collection. ( """\t\t\tvar toggles = state.bulkEditToggles || {}; \t\t\tvar anyChecked = Object.keys( toggles ).some( function ( k ) { return toggles[ k ].checked; } ) || \t\t\t\tObject.keys( state.bulkTrackRenames || {} ).length > 0;""", """\t\t\tvar inputs = state.bulkEditInputs || {}; \t\t\tvar anyChecked = Object.keys( inputs ).some( function ( k ) { \t\t\t\treturn String( inputs[ k ].value || "" ).length > 0; \t\t\t} ) || Object.keys( state.bulkTrackRenames || {} ).length > 0;""", ), ( """\t\t\t\tvar toggle = state.bulkEditToggles[ field.key ]; \t\t\t\tif ( ! toggle || ! toggle.checked ) { \t\t\t\t\treturn; \t\t\t\t} \t\t\t\tvar input = state.bulkEditInputs[ field.key ]; \t\t\t\tif ( field.bext ) { \t\t\t\t\tdescriptionText = input.value; \t\t\t\t} else if ( field.type === "checkbox" ) { \t\t\t\t\tixmlFieldEdits.push( { path: field.ixmlPath, value: input.checked ? "TRUE" : "FALSE" } ); \t\t\t\t} else {""", """\t\t\t\tvar input = state.bulkEditInputs[ field.key ]; \t\t\t\tif ( ! input || ! String( input.value || "" ).length ) { \t\t\t\t\treturn; // Left blank: this field is not touched. \t\t\t\t} \t\t\t\tif ( field.bext ) { \t\t\t\t\tdescriptionText = input.value; \t\t\t\t} else if ( field.type === "checkbox" ) { \t\t\t\t\tixmlFieldEdits.push( { path: field.ixmlPath, value: input.value } ); \t\t\t\t} else {""", ), ] # The export panel. Borrows the bulk-edit panel's classes wholesale so it # inherits the same card and spacing rather than growing a second set of rules # that drift apart. It lives inside a modal shell (see SHEET_HTML), so its own # hidden attribute is what the shell follows. EXPORT_PANEL_HTML = '''
Copies are written to another folder. The originals are never touched.
{scene} {take} {tape} {project} {date} {time} {tc} {name} {n}