header={ "chef": "BeatRig", "dependencies": "ffmpeg,afplay,ffprobe", "ignore_dependencies": "afplay,", "title": "Radio Ad Deliverables", "description": "Convert audio files to various formats for radio ad delivery", "instructions": "Drag and drop audio files to convert them to various formats for radio ad delivery.", "recipe_version": "2.86", "tags": "", "type": "default", "os": "macOS", "palette": "Clean Slate", "spice": "BQ==:G+Hy36kJcnQQYYavb/JV2Il6eCYr6h4hRJHCF9rRbhX5BcazL6cuTgUMdz0xA50VWA5PmmV0Q7aWkiUtNFtbk991l+t0e8IBXMReMIT1GNjnZZDv7n2YRAo0xcSQbY0ixjG6LLH4wGKnspKAoA/v69iLn6N2IH0XzIZwTGdHSo4=", "flavour": "5LMeyw5ujWnXZmUDXDqXaoqa79QmQ7RXlfO0OBrhlZpErG4Onj3ToNsNypbB/DUynZeYJr85fL0s5hDN7iXf45lsG4+fR76T8RCaOky5EMqOtr39wGcVZaFKnqRsinsP7pPHZ3L58E5N2MWjexTuveCFa7m3tDIWh2bxRIjHT3Y=", "time": 1739791999, "core_version": "0.7.4", "magnetron_version": "1.0.341", "functions": "main,onConfig", "uuid": "4e9d8827681e4ba6a7a5b9371054ddb2" }; // ============================================================================= // GLOBAL FOR THE DURATION OF THE CURRENT FILE var files = []; var filesTotalCount = 0; var filesCurCount = 0; var filesCurDuration = 0; var settingsFile = ""; var settings = {}; // ============================================================================= var types = { "preview" : { "label": "Preview", "description": "stereo MP3 files, 192kbps, 44.1kHz, 16bit, -1dB Full Scale, with strailing silence", "tag": "-[preview]", "ext": ".mp3", }, "spotwave" : { "label": "Spotwave", "description": "stereo WAV file, 48kHz, 24bit, Peaks max. at -9dB Full Scale", "tag": "-[spotwave]", "ext": ".wav", }, "fpmedia" : { "label": "F&P Media", "description": "stereo WAV file, 48kHz, 24bit, Peaks max. at -9dB Full Scale, 27seconds", "tag": "-[fpmedia]", "ext": ".wav", "hideSkip": true, // Hide this option from the user select dialog when it is not available }, "olr" : { "label": "Online Radio", "description": "stereo MP3 file, 320kbps, 44.1kHz, 16bit, Peaks at max. -1dB Full Scale", "tag": "-[olr]", "ext": ".mp3", }, "r128" : { "label": "R128", "description": "stereo WAV, 48kHz, 24bit, -23LUFS, Max -1dBTP & Max-M +8LU", "tag": "-[r128]", "ext": ".wav", }, "spotify" : { "label": "Spotify", "description": "stereo MP3 file, 320kbps, 44.1kHz, 16bit, Filesize max. 1mb, -16LUFS, Peaks at max -2dBTP", "tag": "-[spotify]", "ext": ".mp3", }, "streaming" : { "label": "Streaming", "description": "stereo WAV, 44.1kHz, 16bit, -16LUFS, -1dB Full Scale", "tag": "-[streaming]", "ext": ".wav", }, }; // ============================================================================= function processFile_preview(file, outFile) { // adjust audio level var adjust = Math.round(( -1.0-parseFloat(file.truePeak) )*100)/100; // -------------------------------- // FFmpeg arguments var ffmpegArgs = [ // INPUT AUDIO '-i', file.path, // SILENCE INPUT (END) '-f', 'lavfi', '-t', '0.5', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', // Add artwork if available ...(settings.artworkPath ? ['-i', settings.artworkPath] : []), // FILTER, MAPPING, AND METADATA '-filter_complex', `[0:0]volume=${adjust}dB[a1];[a1][1]concat=n=2:v=0:a=1[out]`, '-map', '[out]', ...(settings.artworkPath ? ['-map', '2'] : []), // METADATA '-metadata', `title=${file.basename}`, ...(settings.artist ? ['-metadata', `artist=${settings.artist}`] : []), ...(settings.comment ? ['-metadata', `comment=${settings.comment}`] : []), // Add artwork-specific metadata if artwork is present ...(settings.artworkPath ? [ '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (Front)"' ] : []), // AUDIO SETTINGS '-f', 'mp3', '-b:a', '192k', '-ar', '44100', '-ac', '2', // OUTPUT FILE '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', outFile, '-y' ]; // Run FFmpeg command cmd("ffmpeg", ffmpegArgs); // -------------------------------- // VALIDATE FILE if (!fileExists(outFile)) return ["File could not be created"]; if (getFileBytes(outFile) == 0) return ["File could not be created"]; return true; } // ============================================================================= function processFile_olr(file, outFile) { // adjust audio level, we use -2dB to make sure the peaks are at -1dB // because of the conversion to mp3 the peaks can slightly increase var adjust = Math.round(( -2.-parseFloat(file.truePeak) )*10)/10; // -------------------------------- var ffmpegArgs = [ // INPUT AUDIO '-i', file.path, // Add artwork if available ...(settings.artworkPath ? ['-i', settings.artworkPath] : []), // ADJUST VOLUME '-af', `volume=${adjust}dB`, // MAP AUDIO '-map', '0', // MAP ARTWORK IF AVAILABLE ...(settings.artworkPath ? ['-map', '1'] : []), // METADATA '-metadata', `artist=${settings.artist}`, '-metadata', `comment=${settings.comment}`, '-metadata', `title=${file.basename}`, // Add artwork-specific metadata if artwork is present ...(settings.artworkPath ? [ '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (Front)"' ] : []), // AUDIO SETTINGS '-f', 'mp3', '-b:a', '320k', '-ar', '44100', '-ac', '2', // OUTPUT FILE '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', outFile, // OVERWRITE '-y' ]; cmd("ffmpeg", ffmpegArgs); // -------------------------------- // VALIDATE FILE if (!fileExists(outFile)) return ["File could not be created"]; if (getFileBytes(outFile) == 0) return ["File could not be created"]; var outSpecs = filespecs(outFile); var levels = levelFileAnalyse(outFile); // Define validation checks var validations = [ { condition: outSpecs.codec_name !== "mp3", message: "Invalid audio codec." }, { condition: outSpecs.sample_rate !== 44100, message: "Invalid sample rate." }, { condition: outSpecs.channels !== 2, message: "Not a stereo file" }, { condition: levels.TruePeak > -1.0, message: `Peaks exceed -1dB TP (${Math.round(levels.TruePeak * 10) / 10} dBTP)` } ]; // Collect and return errors var errors = validations .filter(check => check.condition) .map(check => check.message); return errors.length ? errors : true; } // ============================================================================= function processFile_r128(file, outFile) { // adjust audio level var adjust = Math.round(( -23-parseFloat(file.lufs) )*100)/100; // -------------------------------- // PROCESS FILE cmd("ffmpeg",[ '-i',file.path, '-acodec','pcm_s24le', '-ar','48000', '-filter:a','volume='+adjust+'dB', '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', outFile, '-y', ]); // -------------------------------- // VALIDATE FILE if (!fileExists(outFile)) return ["File could not be created"]; if (getFileBytes(outFile) == 0) return ["File could not be created"]; var fileSpecs = filespecs(outFile); var levels = levelFileAnalyse(outFile); // Define validation checks var validations = [ { condition: fileSpecs.codec_name !== "pcm_s24le", message: "Invalid audio codec." }, { condition: fileSpecs.sample_rate !== 48000, message: "Invalid sample rate." }, { condition: fileSpecs.channels !== 2, message: "Not a stereo file" }, { condition: levels.LUFS > -22.5 || levels.LUFS < -23.5, message: "File is not at -23LUFS" }, { condition: levels.TruePeak > -1.0, message: `Peaks exceed -1dB TP (${Math.round(levels.TruePeak * 10) / 10} dBTP)` }, { condition: levels.Max_M > 8.0, message: "Max_M exceeds 8LU" } ]; // Collect errors var errors = validations .filter(check => check.condition) .map(check => check.message); return errors.length ? errors : true; } // ============================================================================= function processFile_streaming(file, outFile) { // adjust audio level var adjust = Math.round(( -16.0-parseFloat(file.lufs) )*100)/100; // -------------------------------- // PROCESS FILE cmd("ffmpeg",[ '-i',file.path, '-acodec','pcm_s16le', '-ar','44100', '-filter:a','volume='+adjust+'dB', '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', outFile, '-y', ]); // -------------------------------- // VALIDATE FILE if (!fileExists(outFile)) return ["File could not be created"]; if (getFileBytes(outFile) == 0) return ["File could not be created"]; var fileSpecs = filespecs(outFile); var levels = levelFileAnalyse(outFile); // Define validation checks var validations = [ { condition: fileSpecs.codec_name !== "pcm_s16le", message: "Invalid audio codec." }, { condition: fileSpecs.sample_rate !== 44100, message: "Invalid sample rate." }, { condition: fileSpecs.channels !== 2, message: "Not a stereo file" }, { condition: levels.TruePeak > -1.0, message: `Peaks exceed -1dB TP (${levels.TruePeak} dBTP)` }, { condition: levels.LUFS > -15.9 || levels.LUFS < -16.1, message: `Invalid loudness (${Math.round(levels.LUFS * 10) / 10})` } ]; // Collect and return errors var errors = validations .filter(check => check.condition) .map(check => check.message); return errors.length ? errors : true; } // ============================================================================= function processFile_spotwave(file, outFile) { // adjust audio level var adjust = Math.round(( -9.1-parseFloat(file.peak) )*100)/100; // create file file cmd("ffmpeg",[ '-i',file.path, '-acodec','pcm_s24le', '-ar','48000', '-filter:a','volume='+adjust+'dB', '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', outFile, '-y', ]); // -------------------------------- // VALIDATE FILE if (!fileExists(outFile)) return ["File could not be created"]; if (getFileBytes(outFile) == 0) return ["File could not be created"]; var fileSpecs = filespecs(outFile); var levels = levelFileAnalyse(outFile); // Define validation checks var validations = [ { condition: fileSpecs.duration % 5 !== 0, message: "Invalid length" }, { condition: fileSpecs.codec_name !== "pcm_s24le", message: "Invalid codec." }, { condition: fileSpecs.sample_rate !== 48000, message: "Invalid sample rate." }, { condition: fileSpecs.channels !== 2, message: "Invalid channel count" }, { condition: levels.Peak > -9.0, message: `Peaks exceed -9dB (${Math.round((-9.0 - parseFloat(levels.Peak)) * 10) / 10} dB)` } ]; // Collect and return errors var errors = validations .filter(check => check.condition) .map(check => check.message); return errors.length ? errors : true; } // ============================================================================= function processFile_fpmedia(file) { var outFile = outFileName(file, settings.fpmedia_tag, "FPMedia", ".wav"); // adjust audio level var adjust = Math.round(( -9.1-parseFloat(file.peak) )*100)/100; // create file file cmd("ffmpeg",[ '-i',file.path, '-acodec','pcm_s24le', '-ar','48000', '-filter:a','volume='+adjust+'dB', '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', outFile, '-y', ]); // -------------------------------- // VALIDATE FILE if (!fileExists(outFile)) return ["File could not be created"]; if (getFileBytes(outFile) == 0) return ["File could not be created"]; var fileSpecs = filespecs(outFile); var levels = levelFileAnalyse(outFile); // Define validation checks var validations = [ { condition: fileSpecs.duration !== 27, message: "Invalid length" }, { condition: fileSpecs.codec_name !== "pcm_s24le", message: "Invalid codec." }, { condition: fileSpecs.sample_rate !== 48000, message: "Invalid sample rate." }, { condition: fileSpecs.channels !== 2, message: "Invalid channel count" }, { condition: levels.Peak > -9.0, message: "Peaks exceed -9dB" } ]; // Collect and return errors var errors = validations .filter(check => check.condition) .map(check => check.message); return errors.length ? errors : true; } // ============================================================================= function processFile_spotify(file, outFile) { // Adjust audio level var adjust = Math.round((-16. - parseFloat(file.lufs)) * 100) / 100; // Spotify has a 1MB file limit; downgrade to 256K for files longer than 25 seconds var bitrate = (file.duration > 25 ? '256' : '320'); var ffmpegArgs = [ '-i', file.path, ...(settings.artworkPath ? ['-i', settings.artworkPath] : []), '-af', `volume=${adjust}dB`, '-map', '0', ...(settings.artworkPath ? ['-map', '1'] : []), '-metadata', `artist=${settings.artist}`, '-metadata', `comment=${settings.comment}`, '-metadata', `title=${file.basename}`, ...(settings.artworkPath ? [ '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (Front)"' ] : []), '-f', 'mp3', '-b:a', `${bitrate}k`, '-ar', '44100', '-ac', '2', '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', outFile, '-y' ]; // Run FFmpeg command cmd("ffmpeg", ffmpegArgs); // -------------------------------- // VALIDATE FILE if (!fileExists(outFile)) return ["File could not be created"]; if (getFileBytes(outFile) == 0) return ["File could not be created"]; // Check file size var fileSize = getFileBytes(outFile); var fileSpecs = filespecs(outFile); var levels = levelFileAnalyse(outFile); // Define validation checks var validations = [ { condition: fileSize > 1000000, message: "File is larger than 1MB" }, { condition: fileSpecs.duration < 15 || fileSpecs.duration > 30.1, message: "Invalid length" }, { condition: fileSpecs.codec_name !== "mp3", message: "Invalid codec" }, { condition: fileSpecs.sample_rate !== 44100, message: "Invalid sample rate" }, { condition: fileSpecs.channels !== 2, message: "Invalid channel count" }, { condition: levels.LUFS > -15.0 || levels.LUFS < -17.0, message: "Invalid loudness" }, { condition: levels.TruePeak > -2.0, message: "Peaks exceed -2dBTP" } ]; // Collect and return errors var errors = validations .filter(check => check.condition) .map(check => check.message); return errors.length ? errors : true; } // ============================================================================= // READY, STEADY, COOK function main() { // ------------------------------------ setMainMessage("preparing"); // ------------------------------------ // LOAD SETTINGS loadConfig(); // ------------------------------------ // CHECK IF FFMPEG IS AVAILABLE if(getAllowedApps("ffmpeg") == ''){ setMainMessage(""); abort("Could not start FFMPEG. Install it from the settings." ); } // CHECK IF FFPROBE IS AVAILABLE if(getAllowedApps("ffprobe") == ''){ setMainMessage(""); abort("Could not start FFPROBE. Install it from the settings." ); } // ------------------------------------ // GET FILES FROM THE USER files = getFiles(); filesTotalCount = files.length; // CHECK IF THERE IS AT LEAST ONE FILE if(filesTotalCount < 1){ setMainMessage(""); setProgress(0); abort("No files ","Add files before running the recipe.","w"); } // ------------------------------------ // PREPARE AND ANALYSE FILES prepareFiles(); // IF THERE ARE FAILED FILES, ABORT const failed = files.some(file => file.err_msg && file.err_msg.length > 0); if(failed){ setMainMessage("- not all files can be fixed -"); setProgress(0); abort("Could not validate", "One or more files did not validate.","w"); } // ------------------------------------ // USER SELECT DIALOG setMainMessage(""); setProgress(0); var userSelect = showUserSelect(); if(userSelect.cancel){ setMainMessage("- canceled -"); abort(); } // ------------------------------------ // PROCESS THE FILES processFiles(userSelect); // ------------------------------------ // ALL DONE setProgress(0); setMainMessage(" "); // PLAY AFPLAY SOUND IF AVAILABLE if (getAllowedApps("afplay") != '') { cmd("afplay", ['/System/Library/Sounds/Blow.aiff',]); } // ------------------------------------ } // ============================================================================= function showUserSelect() { // ------------------------------------ // HEADER var form = { "top_spacer" : { "type" : "text", "label" : "", "just" : "l", "bounds" : { "x": 20, "w" : 600, "h" : 10 }, }, "header_help" : { "type" : "texteditor", "default" : "Specify the output formats", "just" : "l", "enabled" : false, "bounds" : { "x": 20, "w" : 350, "h" : 50}, "multiline" : true, "wordwrap" : true, }, "header_spacer" : { "type" : "text", "label" : "", "just" : "l", "bounds" : { "x": 20, "w" : 600, "h" : 10 }, }, }; // ------------------------------------ // ADD THE FORM ELEMENTS Object.keys(types).forEach(key => { let type = types[key]; if (type.skip == true && type.hideSkip) return; form = { ...form, [`${key}_label`]: { "type": "text", "label": type.label, "just": "l", "bounds": { "x": 20, "w": 150 }, "enabled": type.skip ? false : true, }, [`${key}`]: { "type": "togglebox", "bounds": { "y": -1, "x": 150, "w": 30 }, "visible": true, // for now always deselected by default "toggle": false, //type.skip ? false : true, "enabled": type.skip ? false : true, "label": "" }, [`${key}_help`]: { "type": "texteditor", "default": type.skip ? type.skip : type.description, "just": "l", "enabled": false, "bounds": { "y": -1, "x": 200, "w": 350, "h": 50 }, "multiline": true, "wordwrap": true, }, }; }); // ------------------------------------ form = { ...form, // BUTTONS "button_spacer" : { "type" : "text", "label" : "", "just" : "l", "bounds" : { "x": 20, "w" : 400, "h" : 25 }, }, "cancel" : { "type" : "button", "label" : "cancel", "bounds" : { "x": 370, "w" : 100, }, "is_cancel" : true, "returns" : 0 }, "make" : { "type" : "button", "label" : "make", "bounds" : { "y" : -1, "x": 480, "w" : 100 }, "is_okay" : true, "returns" : 1 }, // THIS IS THE END "bottom_spacer" : { "type" : "text", "label" : "", "just" : "l", "bounds" : { "x": 20, "w" : 600, "h" : 10 }, }, }; // ------------------------------------ return dialog(form, 'showUserSelect_callback'); } // ============================================================================= function showUserSelect_callback(){ } // ============================================================================= // CMD CALLBACK // ============================================================================= // FFMPEG CALLBACK function cmd_callback(cmd_name, cmd_output) { // DUE TO A BUG IN THE FFMPEG CALLBACK THE MS IS SHOWN AS MICRO SECONDS, // NOT MILISECCONDS. SO WE NEED TO DIVIDE BY 1000000 TO GET THE CORRECT TIME if (cmd_name === "ffmpeg" && cmd_output.length > 0 && filesCurDuration > 0) { const progress = Math.round((parseInt(cmd_output.match(/out_time_us=(\d+)/)?.pop() || 0, 10) / 1000000) / filesCurDuration * 100); setProgress(progress); } return { "terminate": isCanceled(), // use this option to end the process if there is no better alternative "input": "" // send console input, like a quit signal (followed by a 'return' 0x0D) }; } // ============================================================================= // CONVERT TIME IN SECONDS TO HUMAN READABLE TIME HH:MM:SS:XXX function humanTime(totalSeconds) { var ms = Math.floor(( totalSeconds - Math.floor(totalSeconds) ) * 1000 ); var totalMinutes = Math.floor( totalSeconds / 60 ); var seconds = Math.floor( totalSeconds % 60 ); var hours = Math.floor( totalMinutes / 60 ); var minutes = Math.floor( totalMinutes % 60 ); return String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0") + ":" + String(seconds).padStart(2, "0") + (ms > 0 ? "." + String(ms).padStart(3, "0") : ""); } // ============================================================================= // CALLBACK LOUDNESS ANALYSING // this function gets called when busy LevelFileAnalyse. progress 0. to 1. function LevelFileAnalyse_Progress(file, progress) { setProgress(progress * 100); } // ============================================================================= // FILE SPECS function filespecs(file) { // SET PROGRESS setProgress(101); // FFPROBE COMMAND const ffprobe = cmd("ffprobe", [ "-v", "error", "-print_format", "json", "-show_entries", "stream=codec_type,codec_name,bit_rate,channels,sample_rate,bits_per_sample : format=duration,size", "-i", file, ]); // done setProgress(100); // Parse JSON output const specs = JSON.parse(ffprobe); // Extract audio stream information // const audioStreams = (specs.streams || []).filter(stream => stream.codec_type === "audio"); const audioStream = (specs.streams || []).find(stream => stream.codec_type === "audio"); // If there is only one stream, return the audio stream information // if( specs.streams.length == 1 && audioStream) if(audioStream) { return { codec_name: audioStream?.codec_name || null, channels: parseInt(audioStream?.channels) || 0 , sample_rate: parseInt(audioStream?.sample_rate) || 0, duration: parseFloat(specs.format?.duration) || 0, bits_per_sample: parseInt(audioStream?.bits_per_sample) || 0, bitrate: parseInt(audioStream?.bit_rate) || 0, filesize: parseInt(specs.format?.size) || 0, }; } else { return false; } } // ============================================================================= // DETECT SILENCE. ABSOLUTE SILENCE CAN BE INTERPETED AS SIGNAL LOSS AND // CAUSE WARNINGS DURING BROADCASTING function detect_silence(file) { setProgress(0); var silence_threshold = 60;// (bitrate == 16 ? 95 : 143); var silence_duration= 3; // 3SEC var silence = cmd("ffmpeg", [ '-i',file, '-af','silencedetect=n=-'+silence_threshold+'dB:d='+silence_duration, '-f','null', '-', '-hide_banner', '-loglevel', 'error', '-progress', 'pipe:1', '-nostats', ]); setProgress(100); return (silence.indexOf('silencedetect') != -1); } // ============================================================================= // GENERATE RANDOM ID function makeid(length) { var result = ''; var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var charactersLength = characters.length; for ( let i = 0; i < length; i++ ) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } // ============================================================================= function processFiles(userSelect) { // CLEAN UP THE FILELIST files.forEach(item => { item.file_icon = "HourglassO"; item.file_icon_color = "FFdddddd"; item.file_status = " "; _set(item); }); // PROCESS FILES ONY BY ONE files.forEach((item, index) => { // CURRENT FILE AND DURATION FOR PROGRESS filesCurCount = index; filesCurDuration = parseFloat(item.duration); // ---------------------------------------------------------------- // UPDATE ITEM item.file_icon = "CaretSquareORight"; item.file_icon_color = "FF555555"; item.file_status = "processing..."; _set(item); // ---------------------------------------------------------------- // IF PASSES ALL CHECKS, SET THE ICON TO GREEN item.file_icon = "CheckSquare"; item.file_icon_color = "FF00aa00"; item.file_status = 'Done'; // ---------------------------------------------------------------- // PROCESS FILE for (const [key, type] of Object.entries(types)) { _hasUserCancelled(filesCurCount); // Check if the user selected this type if (!userSelect[key]) continue; setProgress(0); // Start progress _setMsg(`file #: ${key}`); _e(`Processing ${key} for ${item.basename}`); // Generate output filename const outFile = outFileName(item, settings[`${key}_tag`], key, type.ext); // Check if the file already exists if (fileExists(outFile)) { item.err_msg.push(`file already exists: ${key}`); item.file_icon = "Exclamation"; item.file_icon_color = "FFff0000"; item.file_status = 'Failed'; continue; } // Call the processing function dynamically const processFunction = globalThis[`processFile_${key}`]; if (typeof processFunction === 'function') { const result = processFunction(item, outFile); // Handle errors from processing if (result !== true) { if (fileExists(outFile)) deleteFile(outFile); item.err_msg = item.err_msg.concat(result); // indicate failure item.file_icon = "Exclamation"; item.file_icon_color = "FFff0000"; item.file_status = 'Failed'; } else { item.success_msg.push(`file created: ${key}`); } } setProgress(100); } // UPDATE LIST _set(item); }); } // ============================================================================= function outFileName(file, tag, subfolder, ext){ // output folder relative to the input file var outFolder = file.folder+gvar.pss; // if custom folder is set, use that if(settings.customFolder && settings.customFolderPath != ""){ outFolder = settings.customFolderPath+gvar.pss; } // add subfolder if set if(settings.subFolder){ outFolder = outFolder+subfolder+gvar.pss; } // check if folder exists if(!fileExists(outFolder)){ makeFolder(outFolder) }; // output file basename var outFile = outFolder+file.basename; // add tag if set if(settings.nametag && tag != ""){ outFile = outFile+tag; } // add extension outFile = outFile+ext; // return the full path return outFile; } // ============================================================================= function prepareFiles() { // ------------------------------------ // SET DEAULT VALUES, PATH AND CHECK FILE TYPE var fileTypes = [ 'wav','aif','aiff',]; files.forEach(item => { // default values item.file_icon = "HourglassO"; item.file_icon_color = "FFdddddd"; item.file_status = " "; item.err_msg = []; item.success_msg = []; // path info var path = getPathInfo( item.path ); item.basename = path.basename; item.folder = path.folder; item.ext = path.ext; // check extension if( fileTypes.indexOf( toLowerCase(item.ext) ) == -1 ) { item.file_icon_color = "FFff0000"; item.file_icon = "Exclamation"; item.file_status = "This file can not be processed"; item.err_msg.push("Invalid file type. Use WAV or AIFF files ("+toLowerCase(item.ext)+")"); } // update the item _set(item); }); // IF THERE ARE FAILED FILES, ABORT const failed = files.some(file => file.err_msg && file.err_msg.length > 0); if(failed) abort(); // ------------------------------------ // ANALYSE FILES files.forEach((item, index) => { // CURRENT FILE filesCurCount = index; // CHECK IF NOT CANCELED IN THE MEAN TIME _hasUserCancelled(); // MAIN MESSAGE _setMsg("preparing file #"); // UPDATE ITEM item.file_icon = "CaretSquareORight"; item.file_icon_color = "FF777777"; item.file_status = "analysing..."; _set(item); // PREPARE FILE, ANALYSE LOUDNESS ETC item = prepareFile(item); // OPTIONALLY VALIDATE THE FILE IF A FUNCTION IS DEFINED if(typeof validateInputFile == "function"){ _hasUserCancelled(); _setMsg("validating file #"); item = validateInputFile(item); } // CHECK IF THE FILE IS VALID if(item.err_msg.length > 0){ item.file_icon_color = "FFff0000"; item.file_icon = "Exclamation"; item.file_status = "This file can not be processed"; } else{ item.file_icon_color = "FFbbbbbb"; item.file_icon = "squareO"; item.file_status = " "; } // UPDATE THE ITEM _set(item); }); } // ============================================================================= function prepareFile(item) { // DETECT SILENCE var silence = detect_silence(item.path); if( silence ){ item.err_msg.push("A silence longer than 3sec is detected."); return item; } // --------------------------------------------------------------------- // FILE SPECS var specs = filespecs(item.path); item.codec = specs.codec_name; item.samplerate = specs.sample_rate; item.channels = specs.channels; item.bitdepth = specs.bits_per_sample; item.duration = specs.duration; item.durationHuman = humanTime(specs.duration); // --------------------------------------------------------------------- // FILE LOUDNESS AND PEAK var fileLevels = levelFileAnalyse( item.path ); item.lufs = Math.round( fileLevels.LUFS * 100. ) / 100.; item.plr = Math.round( fileLevels.PeakLoudness * 100. ) / 100.; item.peak = Math.round( fileLevels.Peak * 100. ) / 100.; item.truePeak = Math.round( fileLevels.TruePeak * 100. ) / 100.; item.maxM = Math.round( fileLevels.Max_M * 10. ) / 10.; // --------------------------------------------------------------------- // CHECKS if( item.plr > 14.9 ){ // CHECK PLR FOR STREAMING types.streaming.skip = "too dynamic for streaming"; } if( item.duration % 5 != 0 ){ // CHECK DURATION FOR SPOTWAVE types.spotwave.skip = "spotwave expects ads in 5 second increments"; } if( item.maxM > 8. ){ // CHECK DURATION FOR SPOTWAVE types.r128.skip = "R128 expects a max M of 8LU"; } if( item.duration != 27 ){ // CHECK DURATION FOR FPMEDIA types.fpmedia.skip = "F&P Media expects ads of 27 seconds"; } if(item.duration < 15. || item.duration > 30. ){ // SPOTIFY ADS SHOULD BE 15 TO 30 SECONDS types.spotify.skip = "spotify expects ads between 15 and 30 seconds"; } // --------------------------------------------------------------------- return item; } // ============================================================================= function makeTooltip(file) { var tooltip = ""; tooltip += "\- - - - - - - - - - - - - - - - - - - - - - -"; tooltip += "\n[Source File]"; tooltip += "\n- Codec : "+file.codec; tooltip += "\n- Sample rate : "+(parseInt(file.samplerate)/1000.)+"kHz"; tooltip += "\n- Bit depth : "+file.bitdepth+'-bit'; tooltip += "\n- Channels : "+file.channels; tooltip += "\n\n"; tooltip += "\n- Duration : "+file.durationHuman; tooltip += "\n\n"; tooltip += "\n- Loudness : "+toFixed(file.lufs, 1)+'LUFS / Max-M ' + toFixed(file.maxM, 1)+'LU'; tooltip += "\n- Peak : "+toFixed(file.peak, 1)+'dB / '+toFixed(file.truePeak, 1)+'dBTP'; tooltip += "\n- PLR : "+toFixed(file.plr, 1)+'LUFS'; if(file.success_msg.length > 0){ tooltip += "\n\n- - - - - - - - - - - - - - - - - - - - - - -"; tooltip += "\n[Done]\n- " + file.success_msg.join("\n- "); } if(file.err_msg.length > 0){ tooltip += "\n\n- - - - - - - - - - - - - - - - - - - - - - -"; tooltip += "\n[Errors]\n- " + file.err_msg.join("\n- "); } return tooltip; } // ============================================================================= // SETTINGS function onConfig(){ loadConfig(); showConfig(); } // ============================================================================= // LOAD DEFAULT VALUES function loadConfig(clear = false){ // SETTINGS FOLDER if(!fileExists(folders.content)) makeFolder(folders.content); settingsFile = folders.content+gvar.pss+"settings.json"; // DEFAULT SETTINGS settings = { // MP3 ID TAGS & ARTWORK "artworkEnable" : false, "artworkPath" : "", "artist" : "", "comment" : "", // CUSTOM FOLDER "customFolder" : false, "customFolderPath" : folders.home, // SUBFOLDER "subFolder": false, // TAGS "nametag" : true, }; // ADD TAGS FOR EACH OUTPUT TYPE for (const key in types) { if (types.hasOwnProperty(key)) { settings[key + '_tag'] = types[key].tag; } } // CLEAR SETTINGS FILE if(clear){ if (fileExists(settingsFile)) deleteFile(settingsFile); } // ADD/UPDATE SETTINGS if ( fileExists(settingsFile) ){ if (fileExists(settingsFile)) { Object.assign(settings, JSON.parse(readFile(settingsFile))); } } // CHECK IF ARTWORK AND EXISTS, ELSE SET TO FALSE if(!settings.artworkEnable || !fileExists(settings.artworkPath)){ settings.artworkEnable = false; settings.artworkPath = false; } // RETURN THE VALUES return settings; } // ============================================================================= function showConfig() { var form = { // -------------------------------- "title" : { "type" : "text", "label" : header.title, "just" : "l", "bounds" : { "x": 0, "w" : 600, "h" : 40 }, }, // -------------------------------- "spacer0": { "type": "text", "label": "","just": "c", "bounds": { "x": 20, "w": 550, "h": 20 }, }, // -------------------------------- "heading1" : { "type" : "text", "label" : " ", "just" : "l", "bounds" : { "x": 0, "w" : 600, "h" : 40 }, }, // -------------------------------- "customFolder_label" : { "type" : "text", "label" : "Custom folder", "just" : "l", "bounds" : { "x": 0, "w" : 160, }, }, "customFolder": { "type": "togglebox", "bounds" : { "y": -1, "x": 140, "w" : 25, }, "label" : "", "toggle" : settings.customFolder, }, "customFolderPath" : { "type" : "fileselect", "path" : settings.customFolderPath, "editable" : false, "dir" : true, "saving" : true, "label" : "empty", "bounds" : { "y": -1, "x": 180, "w" : 400, }, "visible": settings.customFolder, }, // -------------------------------- "subFolder_label" : { "type" : "text", "label" : "Sub folders", "just" : "l", "bounds" : { "x": 0, "w" : 160, }, }, "subFolder": { "type": "togglebox", "bounds" : { "y": -1, "x": 140, "w" : 25, }, "label" : "", "toggle" : settings.subFolder, }, // -------------------------------- "heading2" : { "type" : "text", "label" : " ", "just" : "l", "bounds" : { "x": 0, "w" : 600, "h" : 40 }, }, // -------------------------------- "nametag_label" : { "type" : "text", "label" : "Name tag", "just" : "l", "bounds" : { "x": 0, "w" : 160, }, }, "nametag": { "type": "togglebox", "bounds" : { "y": -1, "x": 140, "w" : 25, }, "label" : "", "toggle" : settings.nametag, }, "nametag_d" : { "type" : "text", "label" : "( add tag to filename )", "just" : "l", "bounds" : { "y": -1, "x": 180, "w" : 400, }, }, } // ------------------------------------ // ADD THE FORM ELEMENTS Object.keys(types).forEach(key => { let type = types[key]; form = { ...form, [`${key}_label`]: { "type": "text", "label": type.label, "just": "l", "bounds": { "x": 20, "w": 150 }, }, [`${key}_tag`]: { "type": "texteditor", "bounds" : { "y": -1, "x" : 140, "w" : 200, }, "multiline" : false, "wordwrap" : false, "default" : settings[key + '_tag'], "visible": settings.nametag, }, }; }); // -------------------------------- form = {...form, // -------------------------------- "heading3" : { "type" : "text", "label" : " ", "just" : "l", "bounds" : { "x": 0, "w" : 600, "h" : 40 }, }, // -------------------------------- "artwork_label" : { "type" : "text", "label" : "Artwork", "just" : "l", "bounds" : { "x": 0, "w" : 160, }, }, "artworkEnable": { "type": "togglebox", "bounds" : { "y": -1, "x": 140, "w" : 25, }, "label" : "", "toggle" : settings.artworkEnable, }, "artworkPath" : { "type" : "fileselect", "path" : settings.artworkPath ? settings.artworkPath : "", "editable" : false, "dir" : false, "saving" : false, "label" : "empty", "bounds" : { "y": -1, "x": 180, "w" : 400, }, "visible": settings.artworkEnable, "wildcard" : "*.png, *.jpg, *.jpeg", }, // -------------------------------- "artist_label" : { "type" : "text", "label" : "Artist", "just" : "l", "bounds" : { "x": 0, "w" : 100, }, }, "artist": { "type": "texteditor", "bounds" : { "y": -1, "x" : 140, "w" : 440, }, "multiline" : false, "wordwrap" : false, "default" : settings.artist, }, // -------------------------------- "comment_label" : { "type" : "text", "label" : "Comment", "just" : "l", "bounds" : { "x": 0, "w" : 100, }, }, "comment": { "type": "texteditor", "bounds" : { "y": -1, "x" : 140, "w" : 440, }, "multiline" : false, "wordwrap" : false, "default" : settings.comment, }, // -------------------------------- "heading4" : { "type" : "text", "label" : " ", "just" : "l", "bounds" : { "x": 0, "w" : 600, "h" : 40 }, }, // -------------------------------- "reset" : { "type" : "button", "label" : "reset", "bounds" : { "x": 330, "w" : 80 }, "returns" : -1 }, "cancel" : { "type" : "button", "label" : "cancel", "bounds" : { "y": -1, "x": 415, "w" : 80 }, "returns" : 0 }, "save" : { "type" : "button", "label" : "save", "bounds" : { "y": -1, "x": 500, "w" : 80 }, "returns" : 1 }, // -------------------------------- }; // -------------------------------- // return dialog(header.title, "", "w", form); var r = dialog(form, "showConfig_callback"); // IF RESET IS PUSHED if(r.returns === -1) { settings = loadConfig(true); // load settings and clear the file showConfig(); // OPEN THIS CONFIG FUNCTION AGAIN } // WRITE SETTING BACK TO FILE writeFile( settingsFile, JSON.stringify(settings, null, 4) ); } // ============================================================================ // CALLBACK FUNCTION FOR SETTINGS DIALOG function showConfig_callback(props) { const directMapping = { customFolder: "toggle", customFolderPath: "path", subFolder: "toggle", nametag: "toggle", artworkEnable: "toggle", artworkPath: "path", artist: "text", comment: "text", }; // Add dynamic tags from types Object.keys(types).forEach(key => directMapping[`${key}_tag`] = "text"); // Assign value if it exists in the mapping if (directMapping[props.name]) { settings[props.name] = props[directMapping[props.name]]; } // Configure dialog visibility settings const form = { customFolderPath: { visible: settings.customFolder }, artworkPath: { visible: settings.artworkEnable } }; // Add dynamic tags to the form Object.keys(types).forEach(key => form[`${key}_tag`] = { visible: settings.nametag }); dialog(form, 'showConfig_callback'); } // ============================================================================= const processFunctions = { preview: function(item, outFile) { // adjust audio level var adjust = Math.round(( -1.0-parseFloat(file.truePeak) )*100)/100; // FFmpeg arguments var ffmpegArgs = [ // SILENCE INPUT (BEGIN) // '-f', 'lavfi', // '-t', '0.5', // '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', // INPUT AUDIO '-i', file.path, // SILENCE INPUT (END) '-f', 'lavfi', '-t', '0.5', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100' ]; // Check if artwork is provided and add it in the correct order if (settings.artworkPath) { ffmpegArgs.push( '-i', settings.artworkPath ); } // Continue with filter, mapping, and metadata ffmpegArgs.push( // '-filter_complex', '[1:0]volume=' + adjust + 'dB[a1];[0][a1][2]concat=n=3:v=0:a=1[out]', '-filter_complex', '[0:0]volume=' + adjust + 'dB[a1];[a1][1]concat=n=2:v=0:a=1[out]', '-map', '[out]' ); if (settings.artworkPath) { // ffmpegArgs.push('-map', '3'); ffmpegArgs.push('-map', '2'); } // METADATA // SET TITLE TO FILENAME ffmpegArgs.push( '-metadata', 'title=' + file.basename ); // OPTIONAL ARTIST if(settings.artist != ""){ ffmpegArgs.push( '-metadata', 'artist=' + settings.artist, ); } // OPTIONAL COMMENT if(settings.comment != ""){ ffmpegArgs.push( '-metadata', 'comment=' + settings.comment, ); } // Add artwork-specific metadata if artwork is present if (settings.artworkPath) { ffmpegArgs.push( '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (Front)"' ); } // AUDIO SETTINGS ffmpegArgs.push( '-f', 'mp3', '-b:a', '192k', '-ar', '44100', '-ac', '2', // OUTPUT FILE outFile, '-y' ); // Run FFmpeg command cmd("ffmpeg", ffmpegArgs); // -------------------------------- // VALIDATE FILE var err = []; if( !fileExists(outFile) ){ err.push("File could not be created"); } return (err.length > 0 ? err : true); }, }; // ============================================================================= // HELPER FUNCTION FOR DEBUGGING function _e(...args) { var output = []; args.forEach((arg) => { if (typeof arg === "object") { output.push(JSON.stringify(arg, null, 2)); } else { output.push(arg); } }); echo(output.join("\n")); } // ============================================================================= // SET HELPER function _set(obj) { if(!obj) return; if(!obj.path) return; let path = obj.path; let index = obj.index || 0; // if file_icons exists, set the icon if(obj.file_icon) setFileIcon(path, index, obj.file_icon); // if file_icon_color exists, set the icon color if(obj.file_icon_color) setFileIconColor(path, index, obj.file_icon_color); // if file_status exists, set the status if(obj.file_status) setFileStatus(path, index, obj.file_status); // if file_tooltip exists, set the tooltip const tip = makeTooltip(obj); setFileTooltip(path, index, tip); // // if ttip exists, implode the list and set the tooltip // if(obj.ttip) setFileTooltip(path, index, obj.ttip.join("\n")); } // ============================================================================= // LOAD DEFAULT VALUES function _hasUserCancelled() { if(isCanceled()){ setProgress(0); setMainMessage("- Cancelled -"); if(filesCurCount !== false){ for (i = filesCurCount; i < files.length; i++) { var item = files[i]; item.file_icon = "ExclamationCircle"; item.file_icon_color = "FFff0000"; item.file_status = "Cancelled"; _set(item); } } abort(); } } // ============================================================================= function _setMsg(msg) { msg = msg.replace("#", (filesCurCount+1)+"/"+filesTotalCount); setMainMessage(msg); }