Skip to content

Commit 66e33a7

Browse files
happycubeclaude
andcommitted
CVBS: SMPTE 272M audio profile (48 kHz, genuine 24-bit, synchronous)
The CVBS spec (submodule -> d801241) replaced its per-track audio model with the SMPTE 272M-1994 profile: audio is now stored as channel pairs, one stereo WAV each, at 48 kHz / 24-bit, clock-locked to video. Writer (cvbs.py): - schema user_version 10: drop the file-level audio_locked column, add the audio_channel_pair table (one row per stored pair) - WAV is 48 kHz, 24-bit signed LE; file named <base>_audio_0.wav - enforce the normative synchronous sample count at close: 1920/frame (PAL) or the 8008-per-5-frame 1602/1601 sequence (NTSC/PAL_M), via a small trim/zero-pad of accumulated fractional slack Audio path: make the output genuinely 24-bit rather than a <<8 promotion of the 16-bit decode. dsa_rescale_and_clip takes a full-scale code and _downscale_audio_to_output emits int32; downscale_audio(bits=...) narrows to int16 only for the 16-bit path. bit-depth is threaded through downscale_audio_out and the worker cfg; CVBS runs it at bits=24. The 16-bit .pcm output is unchanged (bit-identical: 16-bit uses fullscale 32767 and the values stay in int16 range). decoder.py forces the CVBS analog-audio rate to 48 kHz regardless of --analog_audio_frequency/--ntsc_audio_rate. cvbs_verify.py updated to check schema v10, the channel-pair row, 48 kHz/24-bit WAV, and the exact synchronous sample total. Verified: NTSC+PAL decodes pass cvbs_verify; low audio byte carries real resolution (all 256 values, not forced-zero); -t parallel audio is bit-identical to serial; 16-bit .pcm cmp-identical before/after. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 29ec686 commit 66e33a7

8 files changed

Lines changed: 163 additions & 63 deletions

File tree

analysis/cvbs_verify.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,15 @@ def main():
9090
check(os.path.exists(meta), ".meta present")
9191
con = sqlite3.connect(meta)
9292
uv = con.execute("PRAGMA user_version").fetchone()[0]
93-
check(uv == 8, f"user_version = 8 (got {uv})")
93+
check(uv == 10, f"user_version = 10 (got {uv})")
9494
row = con.execute(
9595
"SELECT preset, sample_encoding_preset, signal_state_preset, "
96-
"signal_type, decoder, number_of_sequential_frames, audio_locked "
96+
"signal_type, decoder, number_of_sequential_frames "
9797
"FROM cvbs_file").fetchone()
9898
check(row is not None, "cvbs_file row present")
99-
preset_name, enc, state, sigtype, decoder, n_frames, audio_locked = row
99+
preset_name, enc, state, sigtype, decoder, n_frames = row
100100
print(f" preset={preset_name} enc={enc} state={state} type={sigtype} "
101-
f"decoder={decoder} frames={n_frames} audio_locked={audio_locked}")
101+
f"decoder={decoder} frames={n_frames}")
102102
check(preset_name in PRESETS, f"known preset {preset_name}")
103103
p = PRESETS[preset_name]
104104

@@ -312,30 +312,38 @@ def main():
312312
else:
313313
warn("no EFM extension sidecar")
314314

315-
# --- audio ---
316-
wav = base + "_audio_00.wav"
315+
# --- audio (SMPTE 272M: 48 kHz, 24-bit stereo, synchronous) ---
316+
pairs = con.execute(
317+
"SELECT channel_pair, description FROM audio_channel_pair "
318+
"ORDER BY channel_pair").fetchall()
319+
wav = base + "_audio_0.wav"
317320
if os.path.exists(wav):
321+
check(any(cp == 0 for cp, _ in pairs),
322+
"audio_channel_pair row present for pair 0")
318323
with open(wav, "rb") as f:
319324
hdr = f.read(44)
320325
riff, wave = hdr[0:4], hdr[8:12]
321326
fmt_tag, channels, rate = struct.unpack("<HHI", hdr[20:28])
322327
bits = struct.unpack("<H", hdr[34:36])[0]
323328
data_len = struct.unpack("<I", hdr[40:44])[0]
324329
check(riff == b"RIFF" and wave == b"WAVE", "WAV RIFF header")
325-
check(fmt_tag == 1 and channels == 2 and bits == 16,
326-
"WAV stereo s16 PCM")
327-
expect_rate = (44056 if (audio_locked and preset_name in
328-
("NTSC", "PAL_M")) else 44100)
329-
check(rate == expect_rate,
330-
f"WAV rate {rate} matches audio_locked={audio_locked}")
330+
check(fmt_tag == 1 and channels == 2 and bits == 24,
331+
"WAV stereo 24-bit PCM")
332+
check(rate == 48000, f"WAV rate 48000 (got {rate})")
331333
actual = os.path.getsize(wav) - 44
332334
check(data_len == actual,
333335
f"WAV data chunk size correct ({data_len} vs {actual})")
334-
if audio_locked:
335-
per_frame = 1764 if preset_name == "PAL" else 1470
336-
check(data_len == file_frames * per_frame * 4,
337-
f"locked audio: {per_frame} samples/frame exactly")
336+
# synchronous per-preset total: 1920/frame (PAL) or the 8008-per-
337+
# 5-frame 1602/1601 sequence (NTSC/PAL_M)
338+
if preset_name == "PAL":
339+
target = 1920 * file_frames
340+
else:
341+
seq = (0, 1602, 3203, 4805, 6406)
342+
target = 8008 * (file_frames // 5) + seq[file_frames % 5]
343+
check(data_len == target * 6,
344+
f"synchronous audio: {target} samples for {file_frames} frames")
338345
else:
346+
check(not pairs, "no audio_channel_pair rows without a WAV file")
339347
warn("no audio WAV present")
340348

341349
print()

lddecode/audio.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ def _downscale_audio_compute_locs_and_swow(
8282

8383
@njit(cache=True, nogil=True)
8484
def _downscale_audio_to_output(
85-
arange, locs, swow, audio_left, audio_right, audio_lfreq, audio_rfreq
85+
arange, locs, swow, audio_left, audio_right, audio_lfreq, audio_rfreq,
86+
fullscale
8687
):
8788
"""decimate audio to final output samples.
8889
@@ -94,11 +95,14 @@ def _downscale_audio_to_output(
9495
audio_right (np.array(float)): right channel demodulated audio
9596
audio_lfreq (float): left audio channel frequency
9697
audio_rfreq (float): right audio channel frequency
98+
fullscale (float): positive full-scale code (32767 for 16-bit,
99+
8388607 for 24-bit) - sets the output resolution
97100
Returns: (tuple)
98-
output (np.ndarray(int16)): output audio waveform
101+
output (np.ndarray(int32)): output audio waveform (24-bit-capable;
102+
the caller narrows to int16 when fullscale is 32767)
99103
failed (bool): whether there were any failed samples that were muted
100104
"""
101-
output = np.zeros((2 * (len(arange) - 1)), dtype=np.int16)
105+
output = np.zeros((2 * (len(arange) - 1)), dtype=np.int32)
102106

103107
failed = False
104108

@@ -114,18 +118,20 @@ def _downscale_audio_to_output(
114118

115119
# Flipping audio here to line up with ralf/he010 digital sample
116120
# (when comparing, remove the first 265 samples of ralf.pcm as well)
117-
output[(i * 2) + 0] = -dsa_rescale_and_clip(output_left)
118-
output[(i * 2) + 1] = -dsa_rescale_and_clip(output_right)
121+
output[(i * 2) + 0] = -dsa_rescale_and_clip(output_left, fullscale)
122+
output[(i * 2) + 1] = -dsa_rescale_and_clip(output_right, fullscale)
119123
else:
120124
# TBC failure can cause this (issue #389)
121125
failed = True
122126

123127
return output, failed
124128

125129

126-
# Downscales to 16bit/44.1khz. It might be nice when analog audio is better to support 24/96,
127-
# but if we only support one output type, matching CD audio/digital sound is greatly preferable.
128-
def downscale_audio(audio, lineinfo, rf, linecount, timeoffset=0, freq=44100, rv=None):
130+
# Downscales to 16- or 24-bit PCM. 16-bit at 44.1 kHz matches CD/digital
131+
# audio and is the default; 24-bit (bits=24) is used for the CVBS SMPTE 272M
132+
# audio profile, where the demod's sub-16-bit resolution is actually kept.
133+
def downscale_audio(audio, lineinfo, rf, linecount, timeoffset=0, freq=44100,
134+
rv=None, bits=16):
129135
"""downscale audio for output.
130136
131137
Parameters:
@@ -135,11 +141,14 @@ def downscale_audio(audio, lineinfo, rf, linecount, timeoffset=0, freq=44100, rv
135141
linecount (int): # of lines in field
136142
timeoffset (float): time of first audio sample (ignored w/- frequency)
137143
freq (int): Output frequency (negative values are multiple of HSYNC frequency)
144+
bits (int): Output resolution, 16 (int16) or 24 (int32, 24-bit values)
138145
Returns: (tuple)
139-
output16 (np.array(int)): Array of 16-bit integers, ready for output
146+
output (np.array(int)): int16 (bits=16) or int32 (bits=24) samples
140147
next_timeoffset (float): Time to start pulling samples in the next frame (ignore if sync4x)
141148
"""
142149

150+
fullscale = float((1 << (bits - 1)) - 1)
151+
143152
locs, swow, arange, frametime = _downscale_audio_compute_locs_and_swow(
144153
lineinfo,
145154
rf.SysParams["line_period"],
@@ -150,21 +159,27 @@ def downscale_audio(audio, lineinfo, rf, linecount, timeoffset=0, freq=44100, rv
150159
rf.Filters["audio_fdiv"],
151160
)
152161

153-
output16, failed = _downscale_audio_to_output(
162+
output, failed = _downscale_audio_to_output(
154163
arange,
155164
locs,
156165
swow,
157166
audio["audio_left"],
158167
audio["audio_right"],
159168
rf.SysParams["audio_lfreq"],
160169
rf.SysParams["audio_rfreq"],
170+
fullscale,
161171
)
162172

173+
# int16 for the 16-bit path keeps the .pcm output byte-for-byte as before
174+
# (values sit within int16 range, so the narrowing is exact)
175+
if bits <= 16:
176+
output = output.astype(np.int16)
177+
163178
if failed:
164179
logs.logger.warning("Analog audio processing error, muting samples")
165180

166181
if rv is not None:
167-
rv['dsaudio'] = output16
182+
rv['dsaudio'] = output
168183
rv['audio_next_offset'] = arange[-1] - frametime
169184

170-
return output16, arange[-1] - frametime
185+
return output, arange[-1] - frametime

lddecode/cvbs.py

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def pal_lattice_positions(n_samples, origin_lines=Fraction(0)):
7575

7676

7777
_META_SCHEMA = """
78-
PRAGMA user_version = 8;
78+
PRAGMA user_version = 10;
7979
8080
CREATE TABLE cvbs_file (
8181
cvbs_file_id INTEGER PRIMARY KEY,
@@ -101,20 +101,32 @@ def pal_lattice_positions(n_samples, origin_lines=Fraction(0)):
101101
CHECK (number_of_sequential_frames IS NULL OR number_of_sequential_frames >= 1),
102102
black_level INTEGER,
103103
has_nonstandard_values BOOLEAN,
104-
audio_locked BOOLEAN,
105104
capture_notes TEXT
106105
);
106+
107+
CREATE TABLE audio_channel_pair (
108+
channel_pair INTEGER PRIMARY KEY
109+
CHECK (channel_pair BETWEEN 0 AND 7),
110+
description TEXT
111+
);
107112
"""
108113

109114

110115
class CVBSWriter:
111116
"""Assembles decoded fields into spec-compliant CVBS output.
112117
113118
Writes <basename>.composite (u16le, CVBS_U16_4FSC), <basename>.meta
114-
(SQLite, spec core schema), optional <basename>_audio_00.wav, and the
119+
(SQLite, spec core schema), optional <basename>_audio_0.wav, and the
115120
dropout / EFM extension sidecars (<basename>.dropouts.meta,
116121
<basename>.efm + .efm.meta).
117122
123+
Audio follows the SMPTE 272M-1994 profile the spec mandates: one
124+
channel pair (stereo) stored as a 48 kHz, 24-bit signed little-endian
125+
WAV, synchronous to video. PAL carries 1920 samples/frame exactly;
126+
NTSC/PAL_M carry 8008 samples per 5-frame audio-frame sequence
127+
(1602/1601/1602/1601/1602), so the stored stream is trimmed or
128+
zero-padded at close to exactly offset(N) samples for N frames.
129+
118130
Frames use the ld-decode line convention (sample 0 at the line start,
119131
0H ~ +0.8) — the layout decode-orc's cvbs_source reader expects. One
120132
field pair produces exactly one frame, so extension frame indices align
@@ -134,9 +146,14 @@ class CVBSWriter:
134146
NTSC_LOCK_TARGET = 147.25 # line-referenced burst phase target, deg
135147
LOCK_TOL = 3.0 # residual tolerance for claiming LOCKED
136148

149+
# SMPTE 272M-1994 audio profile (spec's only permitted format)
150+
AUDIO_RATE = 48000 # Hz, synchronous to video
151+
AUDIO_BITS = 24 # signed little-endian PCM
152+
AUDIO_BYTES_PER_SAMPLE = 2 * (AUDIO_BITS // 8) # stereo frame, bytes
153+
137154
def __init__(self, fname_out, system, logger=None, version=None,
138-
black_level=None, write_audio=False, audio_rate=44100,
139-
audio_locked=None, capture_notes=None,
155+
black_level=None, write_audio=False,
156+
audio_description="Analogue stereo", capture_notes=None,
140157
has_nonstandard_values=None, write_efm=False):
141158
self.system = system
142159
self.params = CVBSParams_PAL if system == "PAL" else CVBSParams_NTSC
@@ -176,8 +193,7 @@ def __init__(self, fname_out, system, logger=None, version=None,
176193

177194
# audio
178195
self.write_audio = write_audio
179-
self.audio_rate = audio_rate
180-
self.audio_locked = audio_locked
196+
self.audio_description = audio_description
181197
self.f_wav = None
182198
self._audio_bytes = 0
183199

@@ -388,29 +404,72 @@ def _collect_efm(self, frame_id, efm_a, efm_b):
388404

389405
# -- audio ------------------------------------------------------------
390406

407+
def audio_sample_target(self, n_frames):
408+
"""Normative total stereo-sample count for n_frames of 48 kHz audio.
409+
410+
PAL: 1920 samples/frame exactly. NTSC/PAL_M: an 8008-sample,
411+
5-frame audio-frame sequence (1602/1601/1602/1601/1602), so
412+
offset(n) = 8008*(n // 5) + cumulative offset for (n % 5).
413+
"""
414+
if self.system == "PAL":
415+
return 1920 * n_frames
416+
seq_offset = (0, 1602, 3203, 4805, 6406)
417+
return 8008 * (n_frames // 5) + seq_offset[n_frames % 5]
418+
391419
def push_audio(self, data):
392420
if not self.write_audio:
393421
return
394422
if self.f_wav is None:
395-
self.f_wav = open(self.fname_out + "_audio_00.wav", "wb")
423+
self.f_wav = open(self.fname_out + "_audio_0.wav", "wb")
396424
self.f_wav.write(self._wav_header(0))
397-
buf = data.tobytes() if hasattr(data, "tobytes") else bytes(data)
425+
buf = self._pack_s24(data)
398426
self.f_wav.write(buf)
399427
self._audio_bytes += len(buf)
400428

429+
def _pack_s24(self, data):
430+
"""Pack interleaved 24-bit stereo samples as 24-bit signed LE PCM.
431+
432+
The analog audio decode is run at 24-bit for CVBS (bits=24), so it
433+
arrives as int32 holding genuine 24-bit values in [-2^23, 2^23).
434+
The little-endian int32 low 3 bytes are exactly that value as
435+
24-bit two's-complement LE.
436+
"""
437+
if isinstance(data, (bytes, bytearray)):
438+
data = np.frombuffer(data, dtype=np.int32)
439+
a = np.ascontiguousarray(np.asarray(data, dtype=np.int32))
440+
return np.ascontiguousarray(
441+
a.view(np.uint8).reshape(-1, 4)[:, :3]).tobytes()
442+
401443
def _wav_header(self, data_len):
402-
# spec: stereo s16le PCM; the integer header rate is 44056 for the
403-
# NTSC/PAL_M locked rational rate 44,100,000/1001
404-
rate = int(round(self.audio_rate))
405-
block_align = 4
444+
# spec: stereo 24-bit signed LE PCM at 48 kHz (SMPTE 272M profile)
445+
rate = self.AUDIO_RATE
446+
bits = self.AUDIO_BITS
447+
block_align = self.AUDIO_BYTES_PER_SAMPLE
406448
byte_rate = rate * block_align
407449
return b"".join([
408450
b"RIFF", struct.pack("<I", 36 + data_len), b"WAVE",
409451
b"fmt ", struct.pack("<IHHIIHH", 16, 1, 2, rate, byte_rate,
410-
block_align, 16),
452+
block_align, bits),
411453
b"data", struct.pack("<I", data_len),
412454
])
413455

456+
def _finalise_audio(self):
457+
"""Trim or zero-pad the WAV to the normative synchronous count.
458+
459+
The 48 kHz decode is rate-synchronous, so this only ever adjusts a
460+
handful of samples of accumulated fractional slack to land on the
461+
exact per-preset offset(N) the spec requires.
462+
"""
463+
bps = self.AUDIO_BYTES_PER_SAMPLE
464+
target_bytes = self.audio_sample_target(self.frames_written) * bps
465+
if self._audio_bytes > target_bytes:
466+
self.f_wav.seek(len(self._wav_header(0)) + target_bytes)
467+
self.f_wav.truncate()
468+
self._audio_bytes = target_bytes
469+
elif self._audio_bytes < target_bytes:
470+
self.f_wav.write(b"\x00" * (target_bytes - self._audio_bytes))
471+
self._audio_bytes = target_bytes
472+
414473
# -- close ------------------------------------------------------------
415474

416475
def close(self):
@@ -420,6 +479,7 @@ def close(self):
420479
self.f_video = None
421480

422481
if self.f_wav is not None:
482+
self._finalise_audio()
423483
self.f_wav.seek(0)
424484
self.f_wav.write(self._wav_header(self._audio_bytes))
425485
self.f_wav.close()
@@ -463,13 +523,19 @@ def _write_meta(self, signal_state):
463523
preset, sample_encoding_preset, signal_state_preset,
464524
signal_type, decoder, git_branch, git_commit,
465525
number_of_sequential_frames, black_level,
466-
has_nonstandard_values, audio_locked, capture_notes
467-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
526+
has_nonstandard_values, capture_notes
527+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
468528
(self.system, "CVBS_U16_4FSC", signal_state,
469529
"composite", "ld-decode", git_branch, git_commit,
470530
self.frames_written if self.frames_written else None,
471531
self.black_level, self.has_nonstandard_values,
472-
self.audio_locked, self.capture_notes))
532+
self.capture_notes))
533+
# one channel pair (stereo), SMPTE 272M channels 1 & 2 -> _audio_0.wav
534+
# (every row must correspond to an existing channel-pair file)
535+
if self._audio_bytes:
536+
con.execute(
537+
"INSERT INTO audio_channel_pair (channel_pair, description) "
538+
"VALUES (0, ?)", (self.audio_description,))
473539
con.commit()
474540
con.close()
475541

0 commit comments

Comments
 (0)