SGC Audio Quickstart¶
Uploading Audio to the App¶
To upload an audio course to the app filesystem (hosted on BackBlaze) open clink. (After running the commands below in clink they'll be saved in history, so you can just hit the up arrow to retrieve them, which will save some time.)
SSH into the Hostinger server:
If you have SSH keys setup, you'll be logged in automatically. Then you can just use rclone to copy the files from OneDrive to B2:rclone copy -Pv onedrive:"01 Audio/Spanish/ML" b2:sgc-site/production/uploads/audio/spanish/ML --include=/*.mp3
Timing data in admin interface¶
Save bandwidth¶
Serve the heavy MP3 files from your local device via caddy server:
Files will be served from the /audio subdir beside the caddy exe. You'll need to mirror the folder structure of the prod server, e.g:
The other piece is this Tampermonkey script:
Tampermonkey script
// ==UserScript==
// @name Use local SGC audio early
// @namespace local-sgc-audio
// @version 2026-07-22
// @author You
// @match https://courses.shepherdsglobal.org/admin/audio-job/*
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const CDN_PREFIX =
"https://cdn.shepherdsglobal.org/production/uploads/audio/";
const LOCAL_PREFIX =
"http://127.0.0.1:8765/audio/";
function localize(audio) {
const src = audio.getAttribute("src");
if (!src?.startsWith(CDN_PREFIX)) {
return;
}
audio.setAttribute(
"src",
LOCAL_PREFIX + src.slice(CDN_PREFIX.length)
);
//audio.setAttribute("preload", "metadata");
}
new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) {
continue;
}
if (node.matches("audio[src]")) {
localize(node);
}
node.querySelectorAll?.("audio[src]").forEach(localize);
}
}
}).observe(document.documentElement, {
childList: true,
subtree: true
});
})();
Save your work¶
Sometimes your session expires or you take too long to check the timings, and when you click "Finalize" you get a 419 error. Use this script (in the browser console) to save all your timings so you can quickly redo the work without having to start over from scratch.
Output timings to JSON
// ---------- 1. EXTRACT (snapshot current timing data) ----------
function extractTimingSnapshot() {
const titleInputs = document.querySelectorAll('input[name*="[section_title]"]');
const lessons = {};
titleInputs.forEach(titleInput => {
const name = titleInput.getAttribute('name'); // lessonArr[1][sections][2][section_title]
const match = name.match(/lessonArr\[(\d+)\]\[sections\]\[(\d+)\]/);
if (!match) return;
const [, lessonIdx, sectionIdx] = match;
const timestampName = name.replace('[section_title]', '[section_timestamps]');
const sectionIdName = name.replace('[section_title]', '[section_id]');
const timestampInput = document.querySelector(`input[name="${timestampName}"]`);
const sectionIdInput = document.querySelector(`input[name="${sectionIdName}"]`);
if (!lessons[lessonIdx]) lessons[lessonIdx] = { lesson: lessonIdx, sections: [] };
lessons[lessonIdx].sections.push({
section: sectionIdx,
section_id: sectionIdInput ? sectionIdInput.value : null,
title: titleInput.value,
timestamp: timestampInput ? parseFloat(timestampInput.value) : null
});
});
// sort lessons and sections numerically for readability
const sortedLessons = Object.values(lessons)
.sort((a, b) => a.lesson - b.lesson)
.map(l => ({
...l,
sections: l.sections.sort((a, b) => a.section - b.section)
}));
return {
snapshot_taken: new Date().toISOString(),
lessons: sortedLessons
};
}
// ---------- 2. SAVE as a downloadable JSON file ----------
function downloadTimingSnapshot(filename = 'timing-snapshot.json') {
const data = extractTimingSnapshot();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
return data; // also returned in case you want it in-console
}
// ---------- 3. RESTORE (write a snapshot's timestamps back into the form) ----------
function restoreTimingSnapshot(snapshot) {
let restored = 0;
let missing = [];
snapshot.lessons.forEach(lesson => {
lesson.sections.forEach(section => {
const timestampName = `lessonArr[${lesson.lesson}][sections][${section.section}][section_timestamps]`;
const input = document.querySelector(`input[name="${timestampName}"]`);
if (input && section.timestamp !== null) {
input.value = section.timestamp;
// trigger any listeners the page uses (e.g. to sync the audio player UI)
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
restored++;
} else {
missing.push(timestampName);
}
});
});
console.log(`Restored ${restored} timestamp(s).`);
if (missing.length) console.warn('Could not find inputs for:', missing);
}
// ---------- Usage ----------
// Take & download a snapshot:
downloadTimingSnapshot();
// Later, to restore from a JSON file you loaded into a variable `mySnapshot`:
// restoreTimingSnapshot(mySnapshot);
To restore, copy the following script into the console and hit Enter, then copy the contents of the JSON timing file, and paste it into the textarea the script creates. You'll still need to check/remove any assignment sections, etc that shouldn't be in the audio.
Restore timings from JSON script
function pasteAndRestoreViaTextarea() {
const textarea = document.createElement('textarea');
textarea.style.cssText = 'position:fixed;top:10%;left:10%;width:80%;height:60%;z-index:99999;font-family:monospace;';
textarea.placeholder = 'Paste your timing snapshot JSON here, then click the button below.';
const button = document.createElement('button');
button.textContent = 'Restore Timestamps';
button.style.cssText = 'position:fixed;top:5%;left:10%;z-index:99999;padding:8px 16px;';
button.onclick = () => {
try {
const snapshot = JSON.parse(textarea.value);
restoreTimingSnapshot(snapshot);
textarea.remove();
button.remove();
} catch (e) {
alert('Invalid JSON: ' + e.message);
}
};
document.body.appendChild(textarea);
document.body.appendChild(button);
textarea.focus();
}
pasteAndRestoreViaTextarea();
Edit a lesson and shift timestamps¶
Use the following bookmarklet to auto-shift timestamps by a specified amount:
javascript:(()=>{const offsetStr=prompt("Offset section timestamps by how many seconds? (use negative values to subtract)","0");if(offsetStr===null)return;const offset=parseFloat(offsetStr);if(Number.isNaN(offset)){alert("That was not a valid number.");return;}const inputs=[...document.querySelectorAll(%27input[name^="data["][name$="[section_timestamps]"]%27)];let changed=0;for(const input of inputs){const current=parseFloat(input.value);if(Number.isNaN(current))continue;const updated=Math.max(0,current+offset);input.value=String(updated);input.dispatchEvent(new Event("input",{bubbles:true}));input.dispatchEvent(new Event("change",{bubbles:true}));changed++;}alert(`Updated ${changed} section timestamp${changed===1?"":"s"} by ${offset} second${Math.abs(offset)===1?"":"s"} (clamped at 0).`);})();
Proper phrase breaking in SAB¶
Go to the Changes tab in SAB and Add Change to auto-insert a ZWSP before each line break:
Find: (\n)
Replace: \u200B$1
Warning
When you "Synchronize using aeneas", make sure you split phrases by \u200B, and no other punctuation marks!
Split an MP3 file losslessly using ffmpeg¶
You can split an MP3 into two pieces without re-encoding (and thus without losing quality) using ffmpeg with the stream copy mode:
ffmpeg -i input.mp3 -ss 00:00:00 -to 00:02:30 -c copy part1.mp3
ffmpeg -i input.mp3 -ss 00:02:30 -c copy part2.mp3
-i input.mp3— your source file-ss 00:00:00— start time (hours:minutes:seconds)-to 00:02:30— end time for the first part (e.g. 2 minutes 30 seconds)-ccopy — stream copy mode: copies the audio data as-is, no decoding or re-encoding, so zero quality loss
The second command starts at the split point and runs to the end of the file.
Tip
You can also use -t instead of -to to specify a duration rather than an end timestamp (e.g. -t 00:02:30 means "copy 2m30s starting from -ss").