Skip to content

Commit 75f9c0a

Browse files
authored
Merge pull request #1046 from mpownby/feat/ntsc-locked-audio-rate
feat(audio): add --ntsc_audio_rate for NTSC-locked PCM output
2 parents da689f4 + d531158 commit 75f9c0a

4 files changed

Lines changed: 110 additions & 3 deletions

File tree

docs/user-guide/command.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,18 @@ Set the analog audio output sampling frequency.
320320
ld-decode --analog_audio_frequency 48000 input.ldf output
321321
```
322322

323+
#### `--ntsc_audio_rate`
324+
Output analog audio locked to NTSC line timing instead of the default 44100Hz.
325+
- **Default:** Off (analog audio is output at 44100Hz)
326+
- **Effect:** Produces exactly 2.8 samples per line (1470 samples/frame, 735 per field), giving a sample rate of ~44055.944Hz that stays perfectly aligned to the NTSC video timing with no drift. The default 44100Hz rate corresponds to a non-integer 1471.47 samples/frame, which slowly drifts against the video.
327+
- **Metadata:** The `.tbc.json` reports the resolved `sampleRate` as `44055.944055944055`.
328+
- **Note:** NTSC only. The flag is ignored (with a warning) for PAL, which is already frame-locked at 44100Hz (1764 samples/frame). Overrides `--analog_audio_frequency`.
329+
330+
**Example:**
331+
```bash
332+
ld-decode --ntsc_audio_rate input.ldf output
333+
```
334+
323335
#### `--audio_filterwidth FREQ`
324336
Set the analog audio filter width.
325337
- **Type:** FREQ (see Frequency Format section)

lddecode/core.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3525,7 +3525,10 @@ def __init__(
35253525
self.lpf.add_function(DemodCache.read)
35263526
#self.lpf.add_function(self.decodefield)
35273527

3528-
self.analog_audio = int(analog_audio)
3528+
# Negative values are a multiple of the HSYNC frequency (line-locked
3529+
# output, e.g. -2.8 for NTSC-locked ~44055.944hz) and must keep their
3530+
# fractional part; positive values are a plain integer rate in hz.
3531+
self.analog_audio = analog_audio if analog_audio < 0 else int(analog_audio)
35293532
self.digital_audio = digital_audio
35303533
self.ac3 = extra_options.get("AC3", False)
35313534
self.write_rf_tbc = extra_options.get("write_RF_TBC", False)
@@ -4649,11 +4652,18 @@ def seek(self, startframe, target):
46494652

46504653
def build_json(self):
46514654
""" build up the JSON structure for file output. """
4655+
# A negative analog_audio is a multiple of the HSYNC frequency; report
4656+
# the resolved nominal rate in hz so downstream tools see the real value.
4657+
if self.analog_audio < 0:
4658+
audio_sample_rate = (1000000 / self.rf.SysParams["line_period"]) * -self.analog_audio
4659+
else:
4660+
audio_sample_rate = self.analog_audio
4661+
46524662
pcmAudioParameters = {
46534663
"bits": 16,
46544664
"isLittleEndian": True,
46554665
"isSigned": True,
4656-
"sampleRate": self.analog_audio,
4666+
"sampleRate": audio_sample_rate,
46574667
}
46584668

46594669
jout = {}

lddecode/main.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,16 @@ def main(args=None):
261261
help="RF sampling frequency in source file (default is 44100hz)",
262262
)
263263

264+
parser.add_argument(
265+
"--ntsc_audio_rate",
266+
dest="ntsc_audio_rate",
267+
action="store_true",
268+
default=False,
269+
help="Output analog audio locked to NTSC line timing "
270+
"(2.8 samples/line = 1470 samples/frame, ~44055.944hz) instead of 44100hz. "
271+
"NTSC only; ignored for PAL (already frame-locked at 44100hz).",
272+
)
273+
264274
parser.add_argument(
265275
"--video_bpf_low",
266276
dest="vbpf_low",
@@ -328,6 +338,22 @@ def main(args=None):
328338
print("ERROR: Can only be PAL or NTSC")
329339
sys.exit(1)
330340

341+
# Resolve the analog audio output rate. A negative value is interpreted
342+
# downstream as a multiple of the horizontal line frequency (HSYNC-locked
343+
# output); -2.8 yields exactly 2.8 samples/line (1470 samples/frame),
344+
# locking the audio to NTSC timing at ~44055.944hz. PAL is already
345+
# frame-locked at 44100hz (1764 samples/frame), so the flag is a no-op there.
346+
analog_audio_freq = args.analog_audio_freq
347+
if args.ntsc_audio_rate:
348+
if vid_standard == "NTSC":
349+
analog_audio_freq = -2.8
350+
else:
351+
print(
352+
"WARNING: --ntsc_audio_rate ignored for PAL "
353+
"(audio is already frame-locked at 44100hz)",
354+
file=sys.stderr,
355+
)
356+
331357
# Safety check: ensure --write-test-ldf doesn't overwrite the input file
332358
if args.write_test_ldf is not None:
333359
import os.path
@@ -407,7 +433,7 @@ def main(args=None):
407433
loader,
408434
logger,
409435
est_frames=req_frames,
410-
analog_audio=0 if args.daa else args.analog_audio_freq,
436+
analog_audio=0 if args.daa else analog_audio_freq,
411437
digital_audio=not args.noefm,
412438
system=vid_standard,
413439
doDOD=not args.nodod,

tests/test_audio_rate.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for NTSC-locked analog audio output (--ntsc_audio_rate).
2+
3+
The flag maps to a negative output frequency, which downscale_audio
4+
interprets as a multiple of the horizontal line frequency. -2.8 must
5+
produce exactly 2.8 audio samples per line (1470 per 525-line NTSC
6+
frame), keeping the audio perfectly aligned to the video with no drift.
7+
"""
8+
9+
import numpy as np
10+
11+
from lddecode.core import _downscale_audio_compute_locs_and_swow, SysParams_NTSC
12+
13+
NTSC_LINE_PERIOD = SysParams_NTSC["line_period"] # microseconds
14+
NTSC_FRAME_LINES = 525
15+
16+
17+
def _even_lineinfo(linecount, linelen):
18+
"""Evenly spaced line locations (no wow), with a few lines of padding."""
19+
return np.arange(0, (linecount + 4) * linelen, linelen, dtype=np.double)
20+
21+
22+
def _output_sample_count(freq, linecount=NTSC_FRAME_LINES, linelen=910, scale=32):
23+
"""Run the locs/wow computation and return (n_output_samples, swow)."""
24+
lineinfo = _even_lineinfo(linecount, linelen)
25+
_, swow, arange, _ = _downscale_audio_compute_locs_and_swow(
26+
lineinfo,
27+
NTSC_LINE_PERIOD,
28+
linelen,
29+
linecount,
30+
0.0, # timeoffset (ignored for negative freq)
31+
freq,
32+
scale,
33+
)
34+
# arange carries one extra interpolation tick, so samples = len - 1.
35+
return len(arange) - 1, swow
36+
37+
38+
def test_ntsc_locked_rate_is_exactly_2_8_samples_per_line():
39+
"""freq=-2.8 yields exactly 1470 samples for a full 525-line frame."""
40+
n_samples, swow = _output_sample_count(-2.8)
41+
42+
assert n_samples == 1470 # == 525 * 2.8, integer => no drift
43+
# Evenly spaced lines means unity wow throughout.
44+
np.testing.assert_allclose(swow, 1.0, atol=1e-9)
45+
46+
47+
def test_negative_freq_resolves_to_ntsc_locked_hz():
48+
"""-2.8 corresponds to ~44055.944 Hz (2.8 x the NTSC line frequency)."""
49+
line_freq = 1_000_000 / NTSC_LINE_PERIOD
50+
assert line_freq * 2.8 == 44055.944055944055
51+
52+
53+
def test_default_44100_does_not_frame_lock():
54+
"""The default 44100 Hz rate is a non-integer 1471.47 samples/frame."""
55+
n_samples, _ = _output_sample_count(44100)
56+
57+
# 44100 / (30000/1001) ~= 1471.47, so it cannot equal the locked 1470.
58+
assert n_samples != 1470
59+
assert n_samples == 1471

0 commit comments

Comments
 (0)