fix: cut metadata, J/K cut fields, screen reader section announcements
Cut metadata: WAV and FLAC are now re-encoded (pcm_s16le / flac) instead of stream-copied, so the container header is always rewritten with the correct duration. Lossy formats keep -c copy. J/K shortcuts: now also update the cut panel start/end fields to match the section they landed on, and announce the section aloud. Screen reader: added aria-live polite region; chip clicks and J/K announce 'Section N of M: start to end'; boundary presses announce 'Beginning of sections' or 'End of sections'. Pre-roll: configurable 'Pre-roll' seconds input in the controls bar (default 3 s); all section seeks subtract the pre-roll from the target position so the listener hears context before the loud section begins.
This commit is contained in:
@@ -497,12 +497,19 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
ext = path.suffix.lower()
|
||||
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
||||
|
||||
# For lossless formats, re-encode (not copy) so the container header
|
||||
# is rewritten with the correct duration/size. For lossy formats,
|
||||
# copy is fine — the audio stops at the right frame regardless.
|
||||
_lossless = {'.wav': ['-c:a', 'pcm_s16le'], '.flac': ['-c:a', 'flac']}
|
||||
codec_args = _lossless.get(ext, ['-c', 'copy'])
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=ext)
|
||||
os.close(fd)
|
||||
try:
|
||||
cmd = ['ffmpeg', '-y', '-i', str(path),
|
||||
cmd = ['ffmpeg', '-y',
|
||||
'-i', str(path),
|
||||
'-ss', str(start), '-to', str(end),
|
||||
'-c', 'copy', tmp_path]
|
||||
'-vn'] + codec_args + [tmp_path]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.decode('utf-8', errors='replace')[-400:]
|
||||
@@ -663,6 +670,7 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||||
<a href="#main" class="skip">Skip to content</a>
|
||||
<header>
|
||||
<h1>ISR Archive</h1>
|
||||
@@ -675,6 +683,10 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
|
||||
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||||
aria-describedby="threshold-hint">
|
||||
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
||||
<label for="preroll-input" style="margin-left:16px">Pre-roll:</label>
|
||||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3"
|
||||
aria-describedby="preroll-hint">
|
||||
<span id="preroll-hint" class="controls-hint">seconds to rewind before section start</span>
|
||||
</div>
|
||||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||
<label for="filter-name">Search:</label>
|
||||
@@ -723,6 +735,23 @@ const fmtT = s => {
|
||||
};
|
||||
const pad = n => String(n).padStart(2,'0');
|
||||
|
||||
function announce(msg) {
|
||||
const el = document.getElementById('sr-announce');
|
||||
if (!el) return;
|
||||
el.textContent = ''; // clear first so same text re-triggers
|
||||
setTimeout(() => { el.textContent = msg; }, 50);
|
||||
}
|
||||
const getPreroll = () => {
|
||||
const v = parseFloat(document.getElementById('preroll-input').value);
|
||||
return isNaN(v) || v < 0 ? 0 : v;
|
||||
};
|
||||
function setCutFields(idx, startSec, endSec) {
|
||||
const startEl = document.getElementById('cut-start-'+idx);
|
||||
const endEl = document.getElementById('cut-end-'+idx);
|
||||
if (startEl) startEl.value = fmtT(startSec);
|
||||
if (endEl && endSec != null) endEl.value = fmtT(endSec);
|
||||
}
|
||||
|
||||
// idx -> filename, for live-status polling
|
||||
const recMap = new Map();
|
||||
// idx -> [{start,end}], populated after analysis
|
||||
@@ -804,19 +833,20 @@ function parseTime(s) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
function seekToSection(idx, filename, startSec, endSec) {
|
||||
function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||
const pbtn = document.getElementById('pbtn-'+idx);
|
||||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||||
activePlayerIdx = idx;
|
||||
const audio = document.getElementById('aud-'+idx);
|
||||
const doSeek = () => { audio.currentTime = startSec; };
|
||||
const seekTo = Math.max(0, startSec - getPreroll());
|
||||
const doSeek = () => { audio.currentTime = seekTo; };
|
||||
if (audio.readyState >= 1) doSeek();
|
||||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||
// Pre-fill cut panel fields with section boundaries
|
||||
const startEl = document.getElementById('cut-start-'+idx);
|
||||
const endEl = document.getElementById('cut-end-'+idx);
|
||||
if (startEl) startEl.value = fmtT(startSec);
|
||||
if (endEl && endSec != null) endEl.value = fmtT(endSec);
|
||||
setCutFields(idx, startSec, endSec);
|
||||
if (sectionIdx != null) {
|
||||
const total = (sectionMap.get(idx) || []).length;
|
||||
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtT(startSec)} to ${fmtT(endSec)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyse(idx, filename, cell, btn) {
|
||||
@@ -842,12 +872,12 @@ async function analyse(idx, filename, cell, btn) {
|
||||
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
|
||||
if (d.sections && d.sections.length) {
|
||||
sectionMap.set(idx, d.sections);
|
||||
d.sections.forEach(s => {
|
||||
d.sections.forEach((s, si) => {
|
||||
const c = document.createElement('button');
|
||||
c.className='chip'; c.setAttribute('role','listitem');
|
||||
c.title = 'Jump to this section (or use J/K keys)';
|
||||
c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
||||
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end));
|
||||
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
||||
chips.appendChild(c);
|
||||
});
|
||||
} else {
|
||||
@@ -873,20 +903,38 @@ document.addEventListener('keydown', e => {
|
||||
if (!sections.length) return;
|
||||
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||||
if (!audio) return;
|
||||
const preroll = getPreroll();
|
||||
|
||||
if (e.key === 'j' || e.key === 'J') {
|
||||
e.preventDefault();
|
||||
const cur = audio.currentTime;
|
||||
let target = sections[0].start;
|
||||
let targetIdx = -1;
|
||||
for (let i = sections.length - 1; i >= 0; i--) {
|
||||
if (sections[i].start < cur - 1) { target = sections[i].start; break; }
|
||||
if (sections[i].start < cur - 1) { targetIdx = i; break; }
|
||||
}
|
||||
if (targetIdx >= 0) {
|
||||
const s = sections[targetIdx];
|
||||
audio.currentTime = Math.max(0, s.start - preroll);
|
||||
setCutFields(activePlayerIdx, s.start, s.end);
|
||||
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
|
||||
} else {
|
||||
announce('Beginning of sections');
|
||||
}
|
||||
audio.currentTime = target;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'k' || e.key === 'K') {
|
||||
const cur = audio.currentTime;
|
||||
for (const s of sections) {
|
||||
if (s.start > cur + 0.5) { audio.currentTime = s.start; break; }
|
||||
}
|
||||
e.preventDefault();
|
||||
const cur = audio.currentTime;
|
||||
let jumped = false;
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].start > cur + 0.5) {
|
||||
const s = sections[i];
|
||||
audio.currentTime = Math.max(0, s.start - preroll);
|
||||
setCutFields(activePlayerIdx, s.start, s.end);
|
||||
announce(`Section ${i + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
|
||||
jumped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!jumped) announce('End of sections');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user