Skip to content

Commit a09aca3

Browse files
committed
Respect CIA timer rate for PSID tunes (fixes 2SID/multi-SID playback speed)
1 parent fb29e73 commit a09aca3

3 files changed

Lines changed: 85 additions & 49 deletions

File tree

src/player/mod.rs

Lines changed: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ pub fn spawn_player(
171171

172172
thread::Builder::new()
173173
.name("sid-player".into())
174+
.stack_size(4 * 1024 * 1024) // 4MB — shadow CPU needs space for C64 memory
174175
.spawn(move || {
175176
player_loop(cmd_rx, status_tx, engine_name, u64_address, u64_password);
176177
})
@@ -279,9 +280,7 @@ fn player_loop(
279280
}
280281
}
281282
PlayEngine::Native { shadow } => {
282-
// ── U64 native — real hardware plays the SID ─
283-
// Run a shadow CPU locally for visualization only.
284-
// Writes are NOT sent to the device.
283+
// Run shadow CPU for visualization only.
285284
match shadow {
286285
NativeShadow::Psid {
287286
cpu,
@@ -310,15 +309,20 @@ fn player_loop(
310309
}
311310

312311
// ── Absolute-timeline frame pacing ───────────────────
313-
// Advance deadline by exactly one frame period.
314-
// This prevents inter-frame overhead from accumulating
315-
// as drift (was ~8% slow with per-frame Instant::now).
316312
ctx.next_frame += frame_dur;
317313

318-
// If we've fallen behind (e.g. after a pause or load),
319-
// snap the deadline to now rather than fast-forwarding.
320314
let now = Instant::now();
321315
if ctx.next_frame < now {
316+
// Frame overrun — log it periodically
317+
let overrun = now - ctx.next_frame;
318+
if ctx.frame_count % 250 == 0 {
319+
eprintln!(
320+
"[phosphor] frame {} overrun by {}µs (sids={})",
321+
ctx.frame_count,
322+
overrun.as_micros(),
323+
ctx.track_info.num_sids,
324+
);
325+
}
322326
ctx.next_frame = now;
323327
}
324328

@@ -474,8 +478,6 @@ fn handle_cmd(
474478

475479
if native {
476480
// Build context with shadow CPU for visualization.
477-
// The U64 handles actual SID output — the shadow CPU runs
478-
// locally so we can show voice levels in the visualizer.
479481
let header = &sid_file.header;
480482
let num_sids = 1
481483
+ (header.extra_sid_addrs[0] != 0) as usize
@@ -507,8 +509,7 @@ fn handle_cmd(
507509
md5,
508510
};
509511

510-
// Build a shadow emulation engine (same as normal setup,
511-
// but we never send its writes to the device).
512+
// Build shadow emulation for visualization
512513
let mut sid_bases: Vec<u16> = vec![0xD400];
513514
if header.extra_sid_addrs[0] != 0 {
514515
sid_bases.push(header.extra_sid_addrs[0]);
@@ -558,8 +559,6 @@ fn handle_cmd(
558559
}
559560
};
560561

561-
eprintln!("[phosphor] Native + shadow CPU for visualization");
562-
563562
*play_ctx = Some(PlayContext {
564563
engine: PlayEngine::Native { shadow },
565564
trampoline,
@@ -615,8 +614,6 @@ fn handle_cmd(
615614
stop_playback(play_ctx, bridge);
616615

617616
if was_native {
618-
// For native playback, re-send the SID file with new song number
619-
// and rebuild the shadow CPU for visualization.
620617
if let Ok(data) = std::fs::read(&path) {
621618
if let Some(ref mut br) = bridge {
622619
match br.play_sid_native(&data, song) {
@@ -811,15 +808,14 @@ enum PlayEngine {
811808
cpu: CPU<RsidBus, Nmos6502>,
812809
prev_nmi: bool,
813810
},
814-
/// U64 native playback — the real C64 handles SID output.
815-
/// A shadow CPU runs locally (no output to device) for visualization.
811+
/// U64 native playback — real C64 handles SID output.
812+
/// Shadow CPU runs locally for visualization only.
816813
Native {
817814
shadow: NativeShadow,
818815
},
819816
}
820817

821818
/// Shadow CPU for native playback visualization.
822-
/// Runs the same emulation as PSID/RSID but never sends writes to hardware.
823819
enum NativeShadow {
824820
Psid {
825821
cpu: CPU<C64Memory, Nmos6502>,
@@ -899,24 +895,21 @@ fn setup_playback(
899895

900896
let num_sids = sid_bases.len();
901897
let is_multi = num_sids > 1;
902-
// Always use stereo mode — mono tunes get mirrored to both channels
903-
// so sound comes from both speakers.
904898
let use_stereo = true;
905-
// force_stereo: treat 2SID tunes as mono (mirror SID1 to both channels,
906-
// ignoring the second SID). Only applies to exactly 2-SID tunes;
907-
// 3SID/4SID tunes always use their native multi-SID layout.
899+
// force_stereo: treat 2SID tunes as mono (mirror SID1 to both channels).
900+
// Only applies to exactly 2-SID tunes.
908901
let force_mono_2sid = force_stereo && num_sids == 2;
909902
let mono_mode = !is_multi || force_mono_2sid;
910-
let mirror_mono = mono_mode; // mirror single-SID tunes (and forced-stereo 2SID)
903+
let mirror_mono = mono_mode;
911904

912905
// When forcing stereo on a 2SID tune, use only the base SID for mapping
913906
let mapper = if force_mono_2sid {
914907
SidMapper::new(&sid_bases[..1])
915908
} else {
916909
SidMapper::new(&sid_bases)
917910
};
918-
let frame_us = header.frame_us();
919-
let cycles_per_frame = if header.is_pal {
911+
let mut frame_us = header.frame_us();
912+
let mut cycles_per_frame = if header.is_pal {
920913
PAL_CYCLES_PER_FRAME
921914
} else {
922915
NTSC_CYCLES_PER_FRAME
@@ -955,15 +948,17 @@ fn setup_playback(
955948
br.reset();
956949
thread::sleep(Duration::from_millis(50));
957950

958-
// Tell the device how many SIDs are active:
959-
// mono tunes in stereo mode → 2 (mirror to both speakers)
960-
// multi-SID tunes → num_sids (2, 3, or 4)
961-
let active_sids = if use_stereo && mono_mode { 2 } else { num_sids };
962-
if active_sids > 1 {
951+
if use_stereo {
952+
// Tell the device how many SIDs are active:
953+
// mono tunes in stereo mode → set_stereo(1) for mirror
954+
// multi-SID tunes → set_stereo(num_sids - 1)
955+
let active_sids = if mono_mode { 2 } else { num_sids };
963956
br.set_stereo((active_sids - 1) as i32);
964957
} else {
965958
br.set_stereo(0);
966959
}
960+
961+
let active_sids = if use_stereo && mono_mode { 2 } else { num_sids };
967962
for i in 0..active_sids {
968963
let vol_reg = (i as u8) * SID_REG_SIZE + SID_VOL_REG;
969964
br.write(vol_reg, 0x0F);
@@ -1017,8 +1012,42 @@ fn setup_playback(
10171012
}
10181013

10191014
// Clear writes and install play trampoline for PSID
1015+
//
1016+
// For PSID with speed bit set (CIA timing), read the CIA1 Timer A latch
1017+
// that the tune programmed during INIT. This tells us the intended
1018+
// play call rate. Many 2SID tunes use ~100Hz (2× frame rate).
10201019
let engine = match engine {
10211020
PlayEngine::Psid(mut cpu) => {
1021+
// Check if this song uses CIA timing
1022+
let song_idx = song.saturating_sub(1) as u32;
1023+
let speed_bit = if song_idx < 32 {
1024+
(header.speed >> song_idx) & 1
1025+
} else {
1026+
(header.speed >> 31) & 1
1027+
};
1028+
1029+
if speed_bit == 1 && !header.is_rsid {
1030+
let cia_latch = cpu.memory.cia1.timer_a.latch as u64;
1031+
if cia_latch > 0 && cia_latch < 0xFFFF {
1032+
let clock = if header.is_pal { 985248u64 } else { 1022727u64 };
1033+
let cia_us = (cia_latch * 1_000_000) / clock;
1034+
let cia_hz = clock as f64 / cia_latch as f64;
1035+
eprintln!(
1036+
"[phosphor] CIA timing: latch={} cycles = {}µs ({:.1}Hz)",
1037+
cia_latch, cia_us, cia_hz,
1038+
);
1039+
// Override frame timing to match CIA rate
1040+
if cia_us > 0 && cia_us < frame_us {
1041+
frame_us = cia_us;
1042+
cycles_per_frame = cia_latch as u32;
1043+
eprintln!(
1044+
"[phosphor] Frame rate adjusted to {}µs ({:.1}Hz) from CIA timer",
1045+
frame_us, cia_hz,
1046+
);
1047+
}
1048+
}
1049+
}
1050+
10221051
cpu.memory.clear_writes();
10231052
if header.play_address != 0 {
10241053
cpu.memory
@@ -1033,6 +1062,12 @@ fn setup_playback(
10331062
n @ PlayEngine::Native { .. } => n,
10341063
};
10351064

1065+
// If CIA timing adjusted cycles_per_frame, tell the device
1066+
// so flush() generates the correct amount of audio.
1067+
if let Some(ref mut br) = bridge {
1068+
br.set_cycles_per_frame(cycles_per_frame);
1069+
}
1070+
10361071
PlayContext {
10371072
engine,
10381073
trampoline,

src/sid_device.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ pub trait SidDevice: Send {
2222
fn close(&mut self);
2323
fn shutdown(&mut self);
2424

25+
/// Override cycles-per-frame for flush() audio generation.
26+
/// Only meaningful for emulated engine; hardware devices ignore this.
27+
fn set_cycles_per_frame(&mut self, _cycles: u32) {}
28+
2529
/// Send a complete SID file for native playback on real hardware.
2630
///
2731
/// Returns `Ok(true)` if the engine handles playback natively

src/sid_emulated.rs

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -289,11 +289,9 @@ impl EmulatedDevice {
289289
let mut ext1 = ExternalFilter::new();
290290
let mut ext2 = ExternalFilter::new();
291291
let mut ext3 = ExternalFilter::new();
292-
let mut ext4 = ExternalFilter::new();
293292
ext1.set_clock_frequency(clock_freq as f64);
294293
ext2.set_clock_frequency(clock_freq as f64);
295294
ext3.set_clock_frequency(clock_freq as f64);
296-
ext4.set_clock_frequency(clock_freq as f64);
297295

298296
eprintln!(
299297
"[emulated] SID opened: MOS6581, clock={}Hz, output={}Hz, ExternalFilter=ON",
@@ -308,7 +306,7 @@ impl EmulatedDevice {
308306
ext1,
309307
ext2,
310308
ext3,
311-
ext4,
309+
ext4: ExternalFilter::new(),
312310
clock_freq,
313311
sample_rate,
314312
chip_model,
@@ -419,12 +417,6 @@ impl EmulatedDevice {
419417
return;
420418
}
421419

422-
// Apply ExternalFilter to each SID's samples before mixing.
423-
// Done here (before locking audio_buf) to keep the borrow checker happy.
424-
//
425-
// Each ExternalFilter::clock() call is 4 multiplies + 4 adds — negligible cost.
426-
// The LP stage rolls off content above ~15.9kHz.
427-
// The HP stage removes DC, eliminating the "thump" on SID mute/silence.
428420
let filtered1: Vec<i16> = s1.iter().map(|&s| self.ext1.clock(s)).collect();
429421
let filtered2: Vec<i16> = s2.iter().map(|&s| self.ext2.clock(s)).collect();
430422
let filtered3: Vec<i16> = s3.iter().map(|&s| self.ext3.clock(s)).collect();
@@ -440,17 +432,18 @@ impl EmulatedDevice {
440432
let right = if !filtered2.is_empty() {
441433
*filtered2.get(i).unwrap_or(&0)
442434
} else {
443-
left // mono: mirror SID1 (already filtered) to right channel
435+
left // mono: mirror SID1 to right channel
444436
};
445437

446-
// SID3 and SID4: centre-mixed equally into both channels at half volume.
438+
// SID3/SID4 centre-mixed at half volume
447439
let mut centre: i16 = 0;
448440
if !filtered3.is_empty() {
449441
centre = centre.saturating_add(*filtered3.get(i).unwrap_or(&0) / 2);
450442
}
451443
if !filtered4.is_empty() {
452444
centre = centre.saturating_add(*filtered4.get(i).unwrap_or(&0) / 2);
453445
}
446+
454447
if centre != 0 {
455448
buf.push_back((left.saturating_add(centre), right.saturating_add(centre)));
456449
} else {
@@ -505,9 +498,6 @@ impl SidDevice for EmulatedDevice {
505498
);
506499
}
507500

508-
// Update ExternalFilter coefficients to match the new clock frequency.
509-
// Cutoff frequencies are physical constants (RC values), so the coefficients
510-
// change slightly between PAL (~985kHz) and NTSC (~1023kHz).
511501
let freq = self.clock_freq as f64;
512502
self.ext1.set_clock_frequency(freq);
513503
self.ext2.set_clock_frequency(freq);
@@ -523,6 +513,16 @@ impl SidDevice for EmulatedDevice {
523513
);
524514
}
525515

516+
fn set_cycles_per_frame(&mut self, cycles: u32) {
517+
if cycles != self.cycles_per_frame {
518+
eprintln!(
519+
"[emulated] cycles_per_frame: {} → {}",
520+
self.cycles_per_frame, cycles,
521+
);
522+
self.cycles_per_frame = cycles;
523+
}
524+
}
525+
526526
fn reset(&mut self) {
527527
self.sid1.inner().reset();
528528
if let Some(ref mut s) = self.sid2 {
@@ -534,7 +534,6 @@ impl SidDevice for EmulatedDevice {
534534
if let Some(ref mut s) = self.sid4 {
535535
s.inner().reset();
536536
}
537-
// Reset ExternalFilter state — prevents DC transient after reset.
538537
self.ext1.reset();
539538
self.ext2.reset();
540539
self.ext3.reset();
@@ -549,7 +548,6 @@ impl SidDevice for EmulatedDevice {
549548
fn set_stereo(&mut self, mode: i32) {
550549
if mode >= 1 && self.sid2.is_none() {
551550
self.sid2 = Some(self.make_sid());
552-
// ext2 already created and configured; just reset state.
553551
self.ext2.reset();
554552
eprintln!("[emulated] SID2 enabled");
555553
}
@@ -639,7 +637,6 @@ impl SidDevice for EmulatedDevice {
639637
self.ext1.reset();
640638
self.ext2.reset();
641639
self.ext3.reset();
642-
self.ext4.reset();
643640

644641
self.cycles_this_frame = 0;
645642
if let Ok(mut buf) = self.audio_buf.lock() {

0 commit comments

Comments
 (0)