Skip to content

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:

ssh -p 65002 u159938358@217.196.55.99 
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:

cd "H:\Portable Apps\caddy.exe" 
caddy file-server --listen :8765

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:

audio/
└── thai/
    └── ROM/
        └── 01 บทที่ 1 บทนำสู่จดหมายฝาก.mp3

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
How it works:

  • -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)
  • -c copy — 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").