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:
2026-04-29 20:56:51 +02:00
parent abfe81e734
commit a70701f260
+67 -19
View File
@@ -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 01 · 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');
}
});