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()
|
ext = path.suffix.lower()
|
||||||
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
|
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)
|
fd, tmp_path = tempfile.mkstemp(suffix=ext)
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
try:
|
try:
|
||||||
cmd = ['ffmpeg', '-y', '-i', str(path),
|
cmd = ['ffmpeg', '-y',
|
||||||
|
'-i', str(path),
|
||||||
'-ss', str(start), '-to', str(end),
|
'-ss', str(start), '-to', str(end),
|
||||||
'-c', 'copy', tmp_path]
|
'-vn'] + codec_args + [tmp_path]
|
||||||
result = subprocess.run(cmd, capture_output=True, timeout=120)
|
result = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
err = result.stderr.decode('utf-8', errors='replace')[-400:]
|
err = result.stderr.decode('utf-8', errors='replace')[-400:]
|
||||||
@@ -663,6 +670,7 @@ button.cut:hover:not(:disabled){background:#1e3a8a}
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||||||
<a href="#main" class="skip">Skip to content</a>
|
<a href="#main" class="skip">Skip to content</a>
|
||||||
<header>
|
<header>
|
||||||
<h1>ISR Archive</h1>
|
<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"
|
<input type="number" id="threshold-input" min="0" max="1" step="0.005" value="0.05"
|
||||||
aria-describedby="threshold-hint">
|
aria-describedby="threshold-hint">
|
||||||
<span id="threshold-hint" class="controls-hint">RMS 0–1 · sections above this value are marked loud</span>
|
<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>
|
||||||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||||
<label for="filter-name">Search:</label>
|
<label for="filter-name">Search:</label>
|
||||||
@@ -723,6 +735,23 @@ const fmtT = s => {
|
|||||||
};
|
};
|
||||||
const pad = n => String(n).padStart(2,'0');
|
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
|
// idx -> filename, for live-status polling
|
||||||
const recMap = new Map();
|
const recMap = new Map();
|
||||||
// idx -> [{start,end}], populated after analysis
|
// idx -> [{start,end}], populated after analysis
|
||||||
@@ -804,19 +833,20 @@ function parseTime(s) {
|
|||||||
return parts[0];
|
return parts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekToSection(idx, filename, startSec, endSec) {
|
function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||||
const pbtn = document.getElementById('pbtn-'+idx);
|
const pbtn = document.getElementById('pbtn-'+idx);
|
||||||
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
|
||||||
activePlayerIdx = idx;
|
activePlayerIdx = idx;
|
||||||
const audio = document.getElementById('aud-'+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();
|
if (audio.readyState >= 1) doSeek();
|
||||||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||||
// Pre-fill cut panel fields with section boundaries
|
setCutFields(idx, startSec, endSec);
|
||||||
const startEl = document.getElementById('cut-start-'+idx);
|
if (sectionIdx != null) {
|
||||||
const endEl = document.getElementById('cut-end-'+idx);
|
const total = (sectionMap.get(idx) || []).length;
|
||||||
if (startEl) startEl.value = fmtT(startSec);
|
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtT(startSec)} to ${fmtT(endSec)}`);
|
||||||
if (endEl && endSec != null) endEl.value = fmtT(endSec);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyse(idx, filename, cell, btn) {
|
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');
|
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
|
||||||
if (d.sections && d.sections.length) {
|
if (d.sections && d.sections.length) {
|
||||||
sectionMap.set(idx, d.sections);
|
sectionMap.set(idx, d.sections);
|
||||||
d.sections.forEach(s => {
|
d.sections.forEach((s, si) => {
|
||||||
const c = document.createElement('button');
|
const c = document.createElement('button');
|
||||||
c.className='chip'; c.setAttribute('role','listitem');
|
c.className='chip'; c.setAttribute('role','listitem');
|
||||||
c.title = 'Jump to this section (or use J/K keys)';
|
c.title = 'Jump to this section (or use J/K keys)';
|
||||||
c.textContent = `${fmtT(s.start)} – ${fmtT(s.end)}`;
|
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);
|
chips.appendChild(c);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -873,20 +903,38 @@ document.addEventListener('keydown', e => {
|
|||||||
if (!sections.length) return;
|
if (!sections.length) return;
|
||||||
const audio = document.getElementById('aud-'+activePlayerIdx);
|
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
const preroll = getPreroll();
|
||||||
|
|
||||||
if (e.key === 'j' || e.key === 'J') {
|
if (e.key === 'j' || e.key === 'J') {
|
||||||
|
e.preventDefault();
|
||||||
const cur = audio.currentTime;
|
const cur = audio.currentTime;
|
||||||
let target = sections[0].start;
|
let targetIdx = -1;
|
||||||
for (let i = sections.length - 1; i >= 0; i--) {
|
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') {
|
} 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();
|
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