diff --git a/README.md b/README.md index 2dd67ba..6d62e72 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,20 @@ and `--audio-stereo-separation 0-100` narrows the Amiga's hardware left/right panning (100 = full, 0 = mono). Both are also `[audio]` keys and configuration- screen fields. +## Parallel port + +The parallel port is a small device framework (`src/parallel.rs`): whatever is +plugged in -- currently an audio sampler, later perhaps a printer or a game's +protection dongle -- is offered the data lines on a `$BFE101` access. The one +device so far is an **8-bit audio sampler** (digitizer), the kind Amiga sampling +software (ProTracker, OctaMED, AudioMaster) records through. `--parallel sampler` +(or `[parallel] device = "sampler"`, or the launcher's Parallel tab) attaches it; +`--sampler-audio-input NAME` picks a host audio input (`--sampler-list-audio-inputs` +lists them, cpal again, so the same CoreAudio/WASAPI/ALSA path as audio), and +`--sampler-input-gain` sets a preamp. It is a mono sampler -- host left+right are +summed. The input and gain switch live from the runtime menu, and `Cmd+Shift +/-` +(`Alt+Shift +/-`) nudges the gain when a sampler is attached. + ## MIDI Copperline can bridge Paula's serial port to the host's MIDI system, so an diff --git a/copperline.example.toml b/copperline.example.toml index 1e549aa..0924713 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -264,6 +264,23 @@ floppy_sounds_volume = 100 # stereo_separation = 100 +# What is connected to the parallel port. +# [parallel] + +# Device on the port: "none" (default, nothing connected) or "sampler", an 8-bit +# audio digitizer that the Amiga reads to record. Also `--parallel sampler`. +# device = "sampler" + +# Sampler host capture device, matched by a case-insensitive substring against +# the names `--sampler-list-audio-inputs` prints. Omitted uses the system default +# input. Also `--sampler-audio-input`. A mono sampler: host left+right are summed. +# sampler_input = "External Mic" + +# Sampler input gain, a linear multiplier (a preamp), 1.0 = unity, max 16. In the +# GUI and with Cmd/Alt+Shift +/- it steps in dB. Also `--sampler-input-gain`. +# sampler_gain = 1.0 + + # Host input preferences. # [input] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4d6a2c3..0401d84 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -372,6 +372,31 @@ only the `default`/`pipewire` route is offered; pick the output in the desktop sound settings (or route Copperline in `pavucontrol`) and it follows. macOS and Windows select each device directly. +## `[parallel]` + +What is connected to the parallel port. + +```toml +[parallel] +device = "sampler" # "none" (default) or "sampler" +# sampler_input = "..." # host capture device (substring); omit = system default +# sampler_gain = 1.0 # linear input gain (preamp), max 16 +``` + +`device = "sampler"` attaches an 8-bit audio digitizer -- the classic Amiga +parallel-port sampler (AMAS, DSS, the open-amiga-sampler and the like). Sampling +software (ProTracker, OctaMED, AudioMaster) reads it to record. It captures from +`sampler_input` (a host audio input, `--sampler-audio-input`, matched like the +audio device; the default input otherwise), summing left+right into the single +mono input, with `sampler_gain` as a preamp (`--sampler-input-gain`, or +Cmd/Alt+Shift +/- live). The device and its input/gain are also set on the +launcher's **Parallel** tab, and the input and gain switch live from the runtime +menu. Equivalent flags: `--parallel sampler`, `--sampler-audio-input`, +`--sampler-input-gain`, `--sampler-list-audio-inputs` (naming a sampler option +selects the sampler, like a MIDI endpoint selects `--serial midi`). The same +host-audio note above applies to inputs. Nothing here changes the emulated +machine; a host-IO device, not stored in save states. + ## `[input]` ```toml diff --git a/docs/guide/ui.md b/docs/guide/ui.md index 18bc1fa..bbfe79b 100644 --- a/docs/guide/ui.md +++ b/docs/guide/ui.md @@ -22,6 +22,7 @@ The app shortcut modifier is `Cmd` on macOS and `Alt` on Linux/Windows. | `Cmd+K` | `Alt+K` | Open the [debugger console](../debugger/console) | | `Cmd+J` | `Alt+J` | Toggle joystick input mode: gamepad / keyboard (also the status-bar icon) | | `Cmd+Shift+A` | `Alt+Shift+A` | Cycle the audio output: Default, each host device, then Disabled (also the menu's Audio Out item) | +| `Cmd+Shift +/-` | `Alt+Shift +/-` | Raise / lower the parallel-port sampler's input gain, 3 dB per step (only when a sampler is attached) | | `Cmd+W` | `Alt+W` | Toggle Warp Speed (turbo) on / off | | `Cmd+Shift+W` | `Alt+Shift+W` | Cycle the Warp Speed limit: 2x, 4x, 8x, 16x, Max | | `Esc` | `Esc` | Close an open menu, tool window, or overlay panel; otherwise passed through to the Amiga | diff --git a/src/audio.rs b/src/audio.rs index e01e205..f91efce 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -174,7 +174,7 @@ extern "C" fn alsa_ignore_error( /// a no-op error handler once, process-wide, keeping `--list-audio-devices` and /// the picker readable. No-op off Linux, where the handler does not exist. #[cfg(target_os = "linux")] -fn quiet_alsa_probe_logging() { +pub(crate) fn quiet_alsa_probe_logging() { use std::sync::Once; static ONCE: Once = Once::new(); ONCE.call_once(|| unsafe { @@ -183,7 +183,7 @@ fn quiet_alsa_probe_logging() { } #[cfg(not(target_os = "linux"))] -fn quiet_alsa_probe_logging() {} +pub(crate) fn quiet_alsa_probe_logging() {} /// Whether an ALSA device name is a low-level *plugin* handle rather than a /// device a user would pick: the channel-layout plugins (`front`, `surround51`, @@ -196,7 +196,7 @@ fn quiet_alsa_probe_logging() {} /// off any card or device name -- and hidden handles are still selectable by /// name in the config/CLI, since only the displayed list is filtered. A no-op on /// macOS/Windows, whose device names never take this form. -fn is_alsa_plugin_variant(name: &str) -> bool { +pub(crate) fn is_alsa_plugin_variant(name: &str) -> bool { let plugin = name.split(':').next().unwrap_or(name); matches!( plugin, @@ -341,9 +341,10 @@ pub fn list_output_devices() -> Vec { /// Whether `name` is redundant with the GUI picker's own "Default" entry: it is /// ALSA's `default` pseudo-device *and* that is what the system default resolves /// to, so selecting it and selecting "Default" (the `None` option) do the same -/// thing. `default_name` is the host's default output device name. When the -/// default is some other device, `default` is a distinct choice and kept. -fn is_redundant_default(name: &str, default_name: Option<&str>) -> bool { +/// thing. `default_name` is the host's default device name (output or input). +/// When the default is some other device, `default` is a distinct choice and +/// kept. Shared with the sampler input picker ([`crate::sampler`]). +pub(crate) fn is_redundant_default(name: &str, default_name: Option<&str>) -> bool { name.eq_ignore_ascii_case("default") && default_name.is_some_and(|d| d.eq_ignore_ascii_case("default")) } diff --git a/src/bus.rs b/src/bus.rs index 2467ecf..fa85e55 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -987,6 +987,12 @@ pub struct Bus { /// in hundredths. Default 200 (= 2.00 cck/word, the real 68000 figure); /// diagnostic builds can override it for timing experiments. dbg_ext_cck_x100: u32, + /// Optional device on the parallel port (see [`crate::parallel`]). When + /// attached, a CIA-A PRB access ($BFE101) is offered to it: a read may return + /// a driven byte (the sampler's ADC), a write is delivered to it. A host-IO + /// device, not machine state: never serialized, re-attached after a load. + #[serde(skip)] + parallel_device: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -2076,6 +2082,7 @@ impl Bus { bus_accounting: BusAccounting::from_env(), uhres_dual_warned: false, dbg_ext_cck_x100: external_access_cck_x100_setting(), + parallel_device: None, }; bus.configure_chip_dma_masks(); // Re-derive the per-frame capture buffers from the same helper the @@ -2780,6 +2787,22 @@ impl Bus { self.emulated_cck as f64 / PAULA_CLOCK_HZ as f64 } + /// Attach (or detach with `None`) a device on the parallel port. While one is + /// attached the CIA-A PRB access hooks (`cia_a_read`/`cia_a_write`) offer it + /// the data lines. A host-IO device, not machine state. + pub fn attach_parallel_device( + &mut self, + device: Option>, + ) { + self.parallel_device = device; + } + + /// Whether a parallel-port device is currently attached (drives the runtime + /// menu's sampler live-control items). + pub fn has_parallel_device(&self) -> bool { + self.parallel_device.is_some() + } + pub fn emulated_frames(&self) -> u64 { self.emulated_frames } @@ -4107,6 +4130,19 @@ impl Bus { // status lines: /CHNG, /WPRO, /TK0, /RDY. v = (v & !0x3C) | self.floppy.cia_a_status_bits(); } + if reg == REG_PRB && self.parallel_device.is_some() { + // Offer the PRB read to the attached parallel-port device; if it + // drives the data lines (the sampler's ADC) its byte replaces the + // port register value. + let seconds = self.emulated_seconds(); + if let Some(byte) = self + .parallel_device + .as_mut() + .and_then(|dev| dev.read_data(seconds)) + { + v = byte; + } + } trace!("cia_a R reg={:X} sz={} val={:02X}", reg, size, v); self.poll_stats.tick_read("cia_a", reg); v as u64 @@ -4118,6 +4154,13 @@ impl Bus { let reg = reg_from_addr(addr); trace!("cia_a W reg={:X} sz={} val={:02X}", reg, size, byte); let eff = self.cia_a.write(reg, byte); + if reg == REG_PRB { + // Deliver PRB writes to an attached output device (a printer). The + // sampler ignores them (the trait default). + if let Some(dev) = self.parallel_device.as_mut() { + dev.write_data(byte); + } + } if self.cia_a.irq_line_asserted() { self.paula.intreq |= INT_PORTS; } diff --git a/src/bus/tests.rs b/src/bus/tests.rs index 74b7135..ebe9965 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -8887,6 +8887,38 @@ fn joystick_fire_drives_cia_a_pra_fir1() { assert_eq!(bus.cia_a_read((REG_PRA as u64) * 256, 1) & 0x80, 0); } +#[test] +fn parallel_device_drives_cia_a_prb() { + use crate::chipset::cia::REG_PRB; + use crate::parallel::ParallelPortDevice; + + // A mock device driving the data lines, to prove a PRB read is routed to it. + struct MockDevice; + impl ParallelPortDevice for MockDevice { + fn read_data(&mut self, _emu_seconds: f64) -> Option { + Some(0x55) + } + fn label(&self) -> &str { + "mock" + } + } + + let mut bus = empty_bus(); + let prb = (REG_PRB as u64) << 8; + + // No device attached: PRB reads the port register, never the driven byte. + let plain = bus.cia_a_read(prb, 1) as u8; + assert_ne!(plain, 0x55); + + // Attached: the PRB read returns the device's byte. + bus.attach_parallel_device(Some(Box::new(MockDevice))); + assert_eq!(bus.cia_a_read(prb, 1) as u8, 0x55); + + // Detaching restores the plain port read. + bus.attach_parallel_device(None); + assert_eq!(bus.cia_a_read(prb, 1) as u8, plain); +} + #[test] fn joystick_button2_drives_pot1y_through_potgor() { let mut bus = empty_bus(); diff --git a/src/config.rs b/src/config.rs index 4c71bca..07186c7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -153,6 +153,9 @@ pub struct Config { /// Defaults to [`SerialMode::Stdout`], preserving the historical /// terminal-diagnostics behaviour. pub serial: SerialConfig, + /// What is connected to the parallel port (`[parallel]`). Defaults to + /// [`ParallelDevice::None`] -- nothing connected, as before. + pub parallel: ParallelConfig, } /// How much of the overscan field the window presents. The @@ -273,6 +276,52 @@ pub struct SerialConfig { pub listen: Option, } +/// What is connected to the parallel port. Like [`SerialMode`], this selects a +/// device; only its own sub-options apply. Extensible -- a printer or protection +/// dongle would be added here later. Default [`ParallelDevice::None`] behaves +/// exactly as an unconfigured machine (nothing plugged in). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ParallelDevice { + /// Nothing connected (historical default). + #[default] + None, + /// An audio sampler (digitizer) on the data lines. + Sampler, +} + +impl ParallelDevice { + /// Config-string label (round-trips through [`parse_parallel_device`]). + pub fn label(self) -> &'static str { + match self { + Self::None => "none", + Self::Sampler => "sampler", + } + } +} + +/// Resolved `[parallel]` settings. The `sampler_*` fields apply only when +/// `device` is [`ParallelDevice::Sampler`]; they are carried through otherwise +/// so the configuration screen round-trips them unchanged. +#[derive(Debug, Clone, PartialEq)] +pub struct ParallelConfig { + pub device: ParallelDevice, + /// Host capture device (substring match); `None` = system default. The + /// sampler is a mono digitizer -- host L+R are summed into its single input. + pub sampler_input: Option, + /// Input preamp gain, linear (clamped to the sampler's max at open). + pub sampler_gain: f32, +} + +impl Default for ParallelConfig { + fn default() -> Self { + Self { + device: ParallelDevice::None, + sampler_input: None, + sampler_gain: 1.0, + } + } +} + /// A configured hard-drive image: the host path plus an optional volume-name /// override. The override only changes a host *directory* mounted as an /// in-memory FFS volume -- it sets the FFS volume label instead of deriving it @@ -889,6 +938,7 @@ impl Default for Config { phosphor: 0.0, joystick_input_mode: JoystickInputMode::Gamepad, serial: SerialConfig::default(), + parallel: ParallelConfig::default(), } } } @@ -1021,6 +1071,13 @@ pub struct ConfigOverrides { pub audio_channel_mode: Option, /// Stereo separation percent (`--audio-stereo-separation`), 0-100. pub audio_stereo_separation: Option, + /// Parallel-port device (`--parallel`): "none" or "sampler". Parsed like + /// `[parallel] device`. + pub parallel_device: Option, + /// Sampler host capture device (`--sampler-audio-input`), substring match. + pub sampler_input: Option, + /// Sampler input gain (`--sampler-input-gain`), linear multiplier. + pub sampler_gain: Option, } impl ConfigOverrides { @@ -1042,6 +1099,9 @@ impl ConfigOverrides { && self.audio_device.is_none() && self.audio_channel_mode.is_none() && self.audio_stereo_separation.is_none() + && self.parallel_device.is_none() + && self.sampler_input.is_none() + && self.sampler_gain.is_none() } /// Inject the set overrides into the raw config, replacing the values @@ -1100,6 +1160,22 @@ impl ConfigOverrides { if let Some(sep) = self.audio_stereo_separation { raw.audio.stereo_separation = Some(sep); } + if let Some(device) = &self.parallel_device { + raw.parallel.device = Some(device.clone()); + } + if let Some(dev) = &self.sampler_input { + raw.parallel.sampler_input = Some(dev.clone()); + } + if let Some(gain) = self.sampler_gain { + raw.parallel.sampler_gain = Some(gain); + } + // Naming a sampler option selects the sampler device unless `--parallel` + // said otherwise (as a MIDI endpoint selects `--serial midi`). + if self.parallel_device.is_none() + && (self.sampler_input.is_some() || self.sampler_gain.is_some()) + { + raw.parallel.device = Some(ParallelDevice::Sampler.label().to_string()); + } } } @@ -1154,6 +1230,8 @@ pub struct RawConfig { pub(crate) input: RawInput, #[serde(default, skip_serializing_if = "is_default")] pub(crate) serial: RawSerial, + #[serde(default, skip_serializing_if = "is_default")] + pub(crate) parallel: RawParallel, /// `[[zorro]]` board entries, configured in file order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) zorro: Vec, @@ -1221,6 +1299,21 @@ pub(crate) struct RawSerial { pub(crate) listen: Option, } +/// `[parallel]` device connected to the parallel port. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RawParallel { + /// "none" (default) or "sampler". + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) device: Option, + /// Sampler host capture device (substring match); sampler only. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) sampler_input: Option, + /// Sampler input gain, linear multiplier; sampler only. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) sampler_gain: Option, +} + /// A drive image entry in `[ide]`/`[scsi]`. Accepts either a bare path string /// (`master = "disk.hdf"`) or a table carrying an explicit volume-name override /// (`master = { path = "games/", name = "Games" }`). It serializes back to the @@ -1789,6 +1882,32 @@ impl TryFrom for Config { listen: raw.serial.listen.clone(), }; + let parallel = ParallelConfig { + device: match raw.parallel.device.as_deref() { + None => defaults.parallel.device, + Some(s) => match parse_parallel_device(s) { + Ok(d) => d, + Err(e) => { + errors.push(e); + defaults.parallel.device + } + }, + }, + sampler_input: raw + .parallel + .sampler_input + .clone() + .filter(|name| !name.trim().is_empty()), + sampler_gain: match raw.parallel.sampler_gain { + None => defaults.parallel.sampler_gain, + Some(g) if g.is_finite() && g > 0.0 => g, + Some(g) => { + errors.push(anyhow!("[parallel] sampler_gain must be positive, got {g}")); + defaults.parallel.sampler_gain + } + }, + }; + let ide = IdeConfig { master: raw.ide.master.map(drive_image).transpose()?, slave: raw.ide.slave.map(drive_image).transpose()?, @@ -1954,6 +2073,7 @@ impl TryFrom for Config { phosphor, joystick_input_mode, serial, + parallel, }) } } @@ -2001,6 +2121,17 @@ pub(crate) fn parse_serial_mode(s: &str) -> Result { } } +pub(crate) fn parse_parallel_device(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "none" | "off" => Ok(ParallelDevice::None), + "sampler" => Ok(ParallelDevice::Sampler), + _ => Err(anyhow!( + "unknown [parallel] device {:?}: expected \"none\" or \"sampler\"", + s + )), + } +} + fn parse_pacing_budget(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "cycles" | "m68k-cycles" => Ok(PacingBudget::Cycles), @@ -4144,6 +4275,40 @@ mod tests { Ok(()) } + #[test] + fn parallel_device_defaults_none_and_parses_sampler() -> Result<()> { + // Default and older configs (no section) leave the port empty. + assert_eq!(parse_config("")?.parallel.device, ParallelDevice::None); + + let cfg = parse_config( + "[parallel]\ndevice = \"sampler\"\nsampler_input = \"Mic\"\nsampler_gain = 4.0\n", + )?; + assert_eq!(cfg.parallel.device, ParallelDevice::Sampler); + assert_eq!(cfg.parallel.sampler_input.as_deref(), Some("Mic")); + assert_eq!(cfg.parallel.sampler_gain, 4.0); + + assert!(parse_config("[parallel]\ndevice = \"harpsichord\"\n").is_err()); + // `--parallel sampler` selects the device. + let overrides = ConfigOverrides { + parallel_device: Some("sampler".to_string()), + ..Default::default() + }; + assert_eq!( + load_overrides(&overrides)?.parallel.device, + ParallelDevice::Sampler + ); + // Naming a sampler option alone also selects it. + let overrides = ConfigOverrides { + sampler_input: Some("Mic".to_string()), + ..Default::default() + }; + assert_eq!( + load_overrides(&overrides)?.parallel.device, + ParallelDevice::Sampler + ); + Ok(()) + } + #[test] fn cli_audio_device_overrides_config() -> Result<()> { let overrides = ConfigOverrides { diff --git a/src/lib.rs b/src/lib.rs index 8761e76..0fda244 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,10 +40,12 @@ pub mod memory; #[cfg(feature = "midi")] pub mod midi; pub mod net; +pub mod parallel; pub mod priority; pub mod recorder; pub mod romsearch; pub mod rtc; +pub mod sampler; pub mod savestate; pub mod screenshot; pub mod scsi; diff --git a/src/main.rs b/src/main.rs index 9c62513..5c3c984 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,6 +97,8 @@ pub struct CliArgs { pub list_midi: bool, /// `--list-audio-devices`: print the host audio output devices and exit. pub list_audio_devices: bool, + /// `--sampler-list-audio-inputs`: print the host audio *input* devices and exit. + pub list_audio_inputs: bool, /// Command-line machine overrides (`--model`, `--chipset`, `--cpu`, /// `--fpu`/`--no-fpu`, `--cpu-clock`, `--chip`, `--fast`, `--slow`, /// `--floppy-drives`). @@ -253,6 +255,7 @@ where let mut calibrate_gamepad = false; let mut list_midi = false; let mut list_audio_devices = false; + let mut list_audio_inputs = false; let mut overrides = ConfigOverrides::default(); let mut args = args.into_iter(); while let Some(a) = args.next() { @@ -266,6 +269,38 @@ where "--list-audio-devices" => { list_audio_devices = true; } + // Parallel port: `--parallel DEVICE` selects the device (like + // `--serial MODE`), then the device's own `---*` options + // configure it. Naming a sampler option also selects the sampler. + "--parallel" => { + overrides.parallel_device = + Some(args.next().ok_or_else(|| { + anyhow!("--parallel requires a device (none or sampler)") + })?); + } + "--sampler-audio-input" => { + overrides.sampler_input = Some( + args.next() + .ok_or_else(|| anyhow!("--sampler-audio-input requires a device name"))?, + ); + } + "--sampler-input-gain" => { + let v = args + .next() + .ok_or_else(|| anyhow!("--sampler-input-gain requires a value"))?; + let gain = v + .parse::() + .map_err(|_| anyhow!("--sampler-input-gain must be a number, got {v:?}"))?; + if !(gain.is_finite() && gain > 0.0) { + return Err(anyhow!( + "--sampler-input-gain must be a positive number, got {gain}" + )); + } + overrides.sampler_gain = Some(gain); + } + "--sampler-list-audio-inputs" => { + list_audio_inputs = true; + } "--config" | "-c" => { let v = args .next() @@ -627,6 +662,7 @@ where calibrate_gamepad, list_midi, list_audio_devices, + list_audio_inputs, overrides, }) } @@ -695,6 +731,10 @@ fn print_help() { --audio-channel-mode MODE output channels: stereo (default) or mono\n \ --audio-stereo-separation PCT stereo width 0-100 (100 default, 0 = mono)\n \ --list-audio-devices list host audio output devices and exit\n \ + --parallel DEVICE parallel-port device: none (default) or sampler\n \ + --sampler-audio-input NAME sampler host capture device (substring); selects the sampler\n \ + --sampler-input-gain X sampler input gain, linear multiplier (default 1.0, max 16)\n \ + --sampler-list-audio-inputs list host audio input devices and exit\n \ --audio-wav PATH dump mixed stereo audio to a 32-bit float WAV file\n \ \x20 instead of live output\n \ --profile-live-audio SECS run a no-window Paula-to-cpal profile workload;\n \ @@ -934,6 +974,20 @@ fn print_audio_output_devices() -> Result<()> { Ok(()) } +/// Print the host audio input devices for `--sampler-list-audio-inputs`. These +/// are the names `--sampler-audio-input` matches against for the sampler. +fn print_audio_input_devices() -> Result<()> { + println!("Audio input devices (for --sampler-audio-input):"); + let devices = copperline::sampler::list_input_devices(); + if devices.is_empty() { + println!(" (none found)"); + } + for name in devices { + println!(" {name}"); + } + Ok(()) +} + /// Print the host MIDI endpoints for `--list-midi`. This is how a user finds the /// names `--midi-out`/`--midi-in` and `[serial]` expect. Without the `midi` /// feature it says how to get MIDI support rather than printing nothing. @@ -996,6 +1050,9 @@ fn main() -> Result<()> { if cli.list_audio_devices { return print_audio_output_devices(); } + if cli.list_audio_inputs { + return print_audio_input_devices(); + } let (cfg, mut raw_cfg) = load_config(cli.config_path.as_deref(), &cli.overrides)?; if let Some(p) = &cli.rom_path { raw_cfg.rom = Some(p.to_string_lossy().into_owned()); @@ -1004,8 +1061,11 @@ fn main() -> Result<()> { // With nothing specified, open the configuration screen instead of booting // a default machine. Decided before resolving the bundled ROM so the // launcher opens even when no Kickstart/AROS is present. + // The parallel-port sampler is driven by the resolved `[parallel]` config + // (the `--parallel` / `--sampler-*` flags feed it via ConfigOverrides). + let sampler = copperline::sampler::SamplerRequest::from_config(&cfg.parallel); if launcher_requested(&cli) { - return run_configuration_screen(raw_cfg); + return run_configuration_screen(raw_cfg, sampler); } let mut cfg = cfg.with_rom_override(cli.rom_path.clone()); @@ -1099,6 +1159,15 @@ fn main() -> Result<()> { emu.bus().emulated_seconds() ); } + // Attach the parallel-port sampler after any state load (which rebuilds the + // bus). If the input device can't be opened, warn and run without it rather + // than aborting the machine. + if sampler.enabled { + match copperline::sampler::CpalSampler::new(sampler.input_device.as_deref(), sampler.gain) { + Ok(s) => emu.bus_mut().attach_parallel_device(Some(Box::new(s))), + Err(e) => warn!("sampler: could not attach parallel-port sampler: {e:#}"), + } + } // Arm reverse debugging (snapshot ring + optional one-shot "last writer" // watchpoint) from the COPPERLINE_DBG_RR*/RWATCH environment. if let Some(rr) = debugger::reverse_config_from_env() { @@ -1148,6 +1217,7 @@ fn main() -> Result<()> { config::about_machine_lines(&cfg), raw_cfg, live_audio, + sampler, ); // Elevate the thread that is about to run the event loop and the pacer. @@ -1208,7 +1278,10 @@ fn build_placeholder_machine() -> Result { /// started with no machine specified). A placeholder machine sits powered off /// behind the panel until the user presses Run, which builds and starts their /// chosen machine in place. -fn run_configuration_screen(raw_cfg: config::RawConfig) -> Result<()> { +fn run_configuration_screen( + raw_cfg: config::RawConfig, + sampler: copperline::sampler::SamplerRequest, +) -> Result<()> { info!("no machine specified; opening the configuration screen"); let emu = build_placeholder_machine()?; video::set_pixel_aspect(config::resolve_pixel_aspect(config::PixelAspect::Tv)); @@ -1236,6 +1309,7 @@ fn run_configuration_screen(raw_cfg: config::RawConfig) -> Result<()> { vec!["Configure a machine, then press Run.".to_string()], raw_cfg, audio_output_enabled, + sampler, ); app.open_launcher(); app.run() diff --git a/src/parallel.rs b/src/parallel.rs new file mode 100644 index 0000000..1394224 --- /dev/null +++ b/src/parallel.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Parallel-port device framework. +//! +//! The Amiga parallel port is CIA-A port B (`$BFE101`, data lines D0-D7) plus a +//! handful of control/handshake lines. Whatever is plugged into it -- an audio +//! sampler, a printer, a game's protection dongle -- implements +//! [`ParallelPortDevice`], and the bus consults the attached device on a PRB +//! access: +//! +//! - a CPU *read* of PRB calls [`ParallelPortDevice::read_data`]: a device that +//! drives the lines (the audio [`crate::sampler`]) returns the byte; others +//! return `None` and the port register's own value is used. +//! - a CPU *write* to PRB calls [`ParallelPortDevice::write_data`]: an output +//! device (a printer) consumes it; others ignore it (the default). +//! +//! Adding a device: add a `[parallel] device` value ([`crate::config`]), +//! implement this trait, and attach it where the sampler is attached (see +//! `Bus::attach_parallel_device`). Extra handshake lines (a printer's `/STROBE` +//! or `/ACK`) get their own hook here with a default, so existing devices are +//! unaffected. The sampler is the first, and so far only, device. + +/// A device connected to the Amiga's parallel port. Object-safe so the bus can +/// hold `Box`, and free of any host-backend types so the +/// emulator core stays testable. +pub trait ParallelPortDevice { + /// The byte to drive on the data lines for a CPU read of PRB, or `None` to + /// leave the port register's own value in place. `emu_seconds` is the current + /// emulated time, for devices tracking real-time host I/O (the sampler + /// advances its capture by it). The default reads nothing. + fn read_data(&mut self, emu_seconds: f64) -> Option { + let _ = emu_seconds; + None + } + + /// Consume a CPU write to the data lines. For an output device such as a + /// printer; input-only devices ignore it (the default). + fn write_data(&mut self, value: u8) { + let _ = value; + } + + /// A short label for logs and the UI. + fn label(&self) -> &str; +} diff --git a/src/sampler.rs b/src/sampler.rs new file mode 100644 index 0000000..c698173 --- /dev/null +++ b/src/sampler.rs @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Parallel-port audio sampler (digitizer) -- one [`crate::parallel`] device. +//! +//! Classic Amiga 8-bit samplers (AMAS, DSS, Megalosound, the open-amiga-sampler) +//! are a mono ADC on the parallel port: the 8 data lines (D0-D7 = CIA-A port B, +//! `$BFE101`) carry the current sample, which the software reads in a tight, +//! CIA-timer-paced loop to record. The hardware model here -- a straight-binary +//! 8-bit ADC (an ADC0820) on the data lines, mono -- follows the open-amiga- +//! sampler project's schematics (github.com/echolevel/open-amiga-sampler). +//! +//! The emulation approach is an independent Rust implementation following the +//! method in WinUAE's `sampler.cpp` (Toni Wilen; WinUAE is GPL-2.0-or-later, +//! compatible with this GPL-3.0-or-later project): a host capture stream (cpal, +//! mirroring [`crate::audio::CpalSink`]) fills a ring in real time, and each PRB +//! read returns the sample for the elapsed *emulated* time, so the input lines +//! up however fast or slow the Amiga polls. The value is 8-bit offset-binary +//! (128 = silence), as the ADC presents. Host L+R are summed into the single +//! input (these units are mono; [`CpalSampler`] is the live implementation). + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use ringbuf::traits::{Consumer, Observer, Producer, Split}; +use ringbuf::HeapRb; + +/// Set `COPPERLINE_SAMPLER_DEBUG=1` to log the captured input level (peak +/// amplitude) about once a second -- a CLI VU meter to confirm the host mic is +/// feeding the sampler and to gauge input gain. +const SAMPLER_DEBUG_ENV: &str = "COPPERLINE_SAMPLER_DEBUG"; + +/// The largest input gain the sampler preamp will apply (~+24 dB); higher +/// requests are clamped. Past this a real preamp is just clipping anyway. +pub const MAX_SAMPLER_GAIN: f32 = 16.0; + +/// A request to attach a parallel-port sampler, carried from the CLI/config +/// through the window so machines launched from the config screen get it too. +#[derive(Clone)] +pub struct SamplerRequest { + /// Whether a sampler is attached at all. + pub enabled: bool, + /// Host capture device; `None` uses the system default. + pub input_device: Option, + /// Input gain, a linear multiplier applied before the 8-bit ADC conversion, + /// standing in for a real sampler's preamp. 1.0 = unity; clamped to + /// [`MAX_SAMPLER_GAIN`]. + pub gain: f32, +} + +impl Default for SamplerRequest { + fn default() -> Self { + Self { + enabled: false, + input_device: None, + gain: 1.0, + } + } +} + +impl SamplerRequest { + /// Derive the request from resolved `[parallel]` config: enabled only when + /// the connected device is the sampler. + pub fn from_config(parallel: &crate::config::ParallelConfig) -> Self { + Self { + enabled: parallel.device == crate::config::ParallelDevice::Sampler, + input_device: parallel.sampler_input.clone(), + gain: parallel.sampler_gain, + } + } +} + +/// Map a normalised sample in roughly [-1.0, 1.0] to the ADC's 8-bit +/// offset-binary output (0 = -full, 128 = silence, 255 = +full), like the +/// straight-binary ADC0820 on a real sampler. +fn sample_to_byte(v: f32) -> u8 { + let scaled = (v.clamp(-1.0, 1.0) * 127.0).round() as i32 + 128; + scaled.clamp(0, 255) as u8 +} + +/// Names of the host's audio *input* devices, for `--sampler-list-audio-inputs` +/// and as the base for the GUI picker, with ALSA's plugin handles filtered out +/// (as for outputs). ALSA's "default" is kept here so the CLI can name it; the +/// GUI drops it separately (see [`picker_input_devices`]). Empty if the host +/// cannot enumerate; a hidden entry is still selectable by name. +pub fn list_input_devices() -> Vec { + crate::audio::quiet_alsa_probe_logging(); + cpal::default_host() + .input_devices() + .map(|devs| { + devs.filter_map(|d| d.name().ok()) + .filter(|name| !crate::audio::is_alsa_plugin_variant(name)) + .collect() + }) + .unwrap_or_default() +} + +/// Input-device names for the GUI picker (launcher field + runtime menu). Same +/// as [`list_input_devices`], but drops ALSA's "default" when it is the system +/// default input, since the picker already offers a synthetic "Default" (the +/// `None` selection). Re-enumerated on demand, so a device that came online +/// since the screen opened appears. Still selectable by name in the config/CLI. +pub fn picker_input_devices() -> Vec { + let default_name = cpal::default_host() + .default_input_device() + .and_then(|d| d.name().ok()); + list_input_devices() + .into_iter() + .filter(|name| !crate::audio::is_redundant_default(name, default_name.as_deref())) + .collect() +} + +/// Cycle a sampler input selection through "Default" (`None`) then the named +/// devices and back, for the runtime menu. +pub fn next_input_device(current: Option<&str>, names: &[String], forward: bool) -> Option { + let here = current + .and_then(|c| names.iter().position(|n| n == c)) + .map_or(0, |i| i + 1); + let count = names.len() + 1; + let next = if forward { + (here + 1) % count + } else { + (here + count - 1) % count + }; + (next > 0).then(|| names[next - 1].clone()) +} + +/// The input device to open: the first whose name contains `want` +/// (case-insensitive), otherwise the system default. A named-but-missing device +/// warns and falls back to the default rather than failing to attach. +fn select_input_device(host: &cpal::Host, want: Option<&str>) -> Result { + if let Some(name) = want { + let needle = name.to_lowercase(); + let matched = host.input_devices().ok().and_then(|mut devs| { + devs.find(|d| { + d.name() + .map(|n| n.to_lowercase().contains(&needle)) + .unwrap_or(false) + }) + }); + match matched { + Some(device) => return Ok(device), + None => log::warn!("sampler: no input device matches {name:?}; using the default"), + } + } + host.default_input_device() + .ok_or_else(|| anyhow!("no default audio input device")) +} + +/// A live cpal capture feeding the emulated parallel-port ADC. +pub struct CpalSampler { + // Keep the stream alive for the lifetime of the sampler. + _stream: cpal::Stream, + consumer: ringbuf::HeapCons<(f32, f32)>, + /// Host capture sample rate (frames per second). + capture_rate: f64, + /// Drop capture backlog beyond this many frames so reads stay near the live + /// edge (bounded latency), the role of WinUAE's `safediff`. + max_latency_frames: usize, + device_label: String, + /// Emulated time of the previous read, for advancing the read position. + last_seconds: Option, + /// Last summed L+R frame returned, held during underruns and sub-sample reads. + last_mono: f32, + /// Count of CIA-A PRB reads routed here since the meter last reported, so the + /// debug log shows whether the Amiga software is actually polling the port. + reads: Arc, +} + +impl CpalSampler { + /// Open the host capture device (`None` = system default) and start feeding + /// the ring. `gain` is a linear input multiplier (a real sampler's preamp, + /// clamped to [`MAX_SAMPLER_GAIN`]) applied before the ADC conversion. Errors + /// if no device/stream can open. + pub fn new(input_device: Option<&str>, gain: f32) -> Result { + crate::audio::quiet_alsa_probe_logging(); + let gain = if gain > MAX_SAMPLER_GAIN { + log::warn!("sampler: gain {gain} exceeds the max {MAX_SAMPLER_GAIN}x; clamping"); + MAX_SAMPLER_GAIN + } else { + gain + }; + let host = cpal::default_host(); + let device = select_input_device(&host, input_device)?; + let supported = device + .default_input_config() + .map_err(|e| anyhow!("query default input config: {e}"))?; + let sample_format = supported.sample_format(); + let channels = supported.channels() as usize; + let capture_rate = supported.sample_rate().0 as f64; + let config: cpal::StreamConfig = supported.into(); + + // ~2 s ring at the capture rate; we trim backlog on read to stay live. + let capacity = (capture_rate as usize * 2).max(4096); + let rb = HeapRb::<(f32, f32)>::new(capacity); + let (mut producer, consumer) = rb.split(); + + // Opt-in input-level meter: log the peak captured amplitude ~once a + // second so it is obvious whether the host mic is actually feeding the + // sampler (and to gauge input gain). See `SAMPLER_DEBUG_ENV`. + let level_debug = crate::envcfg::flag(SAMPLER_DEBUG_ENV); + let log_every = capture_rate.max(1.0) as u64; + let reads = Arc::new(AtomicU64::new(0)); + let reads_meter = Arc::clone(&reads); + let mut peak = 0.0f32; + let mut counted = 0u64; + // Shared per-frame handler: meter, then push the (l, r) frame. A mono + // device mirrors its one channel to both sides; a full ring drops the + // newest frame (the reader trims to the live edge anyway). + let mut on_frame = move |l: f32, r: f32| { + // Apply the preamp gain up front, so the meter and the ring (and thus + // what the Amiga reads) all reflect the post-gain level. + let l = l * gain; + let r = r * gain; + if level_debug { + peak = peak.max(l.abs()).max(r.abs()); + counted += 1; + if counted >= log_every { + let prb = reads_meter.swap(0, Ordering::Relaxed); + log::info!( + "sampler: input level {:.0}% (peak {peak:.3}), PRB reads/s {prb}", + peak * 100.0 + ); + peak = 0.0; + counted = 0; + } + } + let _ = producer.try_push((l, r)); + }; + + let err_fn = |err| log::warn!("sampler: cpal input stream error: {err}"); + + let stream = match sample_format { + cpal::SampleFormat::F32 => device.build_input_stream( + &config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + for frame in data.chunks(channels) { + let l = frame.first().copied().unwrap_or(0.0); + let r = if channels >= 2 { frame[1] } else { l }; + on_frame(l, r); + } + }, + err_fn, + None, + ), + cpal::SampleFormat::I16 => device.build_input_stream( + &config, + move |data: &[i16], _: &cpal::InputCallbackInfo| { + for frame in data.chunks(channels) { + let l = frame.first().copied().unwrap_or(0) as f32 / 32768.0; + let r = if channels >= 2 { + frame[1] as f32 / 32768.0 + } else { + l + }; + on_frame(l, r); + } + }, + err_fn, + None, + ), + cpal::SampleFormat::U16 => device.build_input_stream( + &config, + move |data: &[u16], _: &cpal::InputCallbackInfo| { + for frame in data.chunks(channels) { + let l = + (frame.first().copied().unwrap_or(32768) as f32 - 32768.0) / 32768.0; + let r = if channels >= 2 { + (frame[1] as f32 - 32768.0) / 32768.0 + } else { + l + }; + on_frame(l, r); + } + }, + err_fn, + None, + ), + other => return Err(anyhow!("unsupported sampler input format {other:?}")), + } + .map_err(|e| anyhow!("build_input_stream: {e}"))?; + stream + .play() + .map_err(|e| anyhow!("input stream play: {e}"))?; + + let device_label = device.name().unwrap_or_else(|_| "".into()); + log::info!( + "sampler: parallel-port sampler ready, device={device_label:?}, \ + capture_rate={capture_rate}, format={sample_format:?}" + ); + + Ok(Self { + _stream: stream, + consumer, + capture_rate, + // ~50 ms of bounded latency. + max_latency_frames: (capture_rate * 0.05) as usize, + device_label, + last_seconds: None, + last_mono: 0.0, + reads, + }) + } +} + +impl crate::parallel::ParallelPortDevice for CpalSampler { + /// A PRB read returns the digitized sample. The ADC always drives the data + /// lines, so this is never `None`. + fn read_data(&mut self, emu_seconds: f64) -> Option { + Some(self.next_byte(emu_seconds)) + } + + fn label(&self) -> &str { + &self.device_label + } +} + +impl CpalSampler { + /// The current 8-bit offset-binary ADC value (128 = silence), advanced + /// through the live capture by the emulated time since the previous read. + /// A mono digitizer: the host L+R are summed into the single ADC input. + fn next_byte(&mut self, emu_seconds: f64) -> u8 { + self.reads.fetch_add(1, Ordering::Relaxed); + // Trim backlog so reads track the live edge instead of a growing delay. + let backlog = self.consumer.occupied_len(); + if backlog > self.max_latency_frames { + self.consumer.skip(backlog - self.max_latency_frames); + } + + // Advance the read position by the emulated time since the last read + // (WinUAE sampler.cpp's approach): faster-than-capture polling repeats a + // frame (advance 0), slower polling averages the frames it skips over -- + // cheap anti-aliasing. + let advance = match self.last_seconds { + Some(prev) => ((emu_seconds - prev).max(0.0) * self.capture_rate).round() as usize, + None => 1, + }; + self.last_seconds = Some(emu_seconds); + + if advance > 0 { + // Sum L+R into the mono input a single-input sampler sees. + let mut sum = 0.0f32; + let mut n = 0usize; + for _ in 0..advance { + match self.consumer.try_pop() { + Some((l, r)) => { + sum += 0.5 * (l + r); + n += 1; + } + None => break, + } + } + if n > 0 { + self.last_mono = sum / n as f32; + } + } + + sample_to_byte(self.last_mono) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn offset_binary_conversion_centres_silence() { + assert_eq!(sample_to_byte(0.0), 128); + assert_eq!(sample_to_byte(1.0), 255); + assert_eq!(sample_to_byte(-1.0), 1); + // Clamps beyond full-scale rather than wrapping. + assert_eq!(sample_to_byte(2.0), 255); + assert_eq!(sample_to_byte(-2.0), 1); + } +} diff --git a/src/video/launcher.rs b/src/video/launcher.rs index e95ad12..b4e2dce 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -23,8 +23,8 @@ use crate::chipset::agnus::{AgnusRevision, VideoStandard}; use crate::chipset::denise::DeniseRevision; use crate::config::{ format_size, machine_profile_defaults, ChannelMode, Chipset, Config, CpuModel, - JoystickInputMode, MachineModel, Overscan, PacingBudget, PixelAspect, RawConfig, RawDrive, - RawFloppyDrive, RawZorroBoard, SerialMode, WarpSpeed, + JoystickInputMode, MachineModel, Overscan, PacingBudget, ParallelDevice, PixelAspect, + RawConfig, RawDrive, RawFloppyDrive, RawZorroBoard, SerialMode, WarpSpeed, }; use crate::zorro::{ConfigOption, ConfigOptionKind, LoadedZorroBoard}; use anyhow::Result; @@ -170,6 +170,7 @@ pub enum LauncherTab { // Only reached in a `midi` build, where it is added to TABS. #[cfg_attr(not(feature = "midi"), allow(dead_code))] Serial, + Parallel, Zorro, AvEmulation, } @@ -187,6 +188,7 @@ pub const TABS: &[LauncherTab] = &[ LauncherTab::Cd, #[cfg(feature = "midi")] LauncherTab::Serial, + LauncherTab::Parallel, LauncherTab::Zorro, LauncherTab::AvEmulation, ]; @@ -202,6 +204,7 @@ impl LauncherTab { LauncherTab::Storage => "Hard Disk", LauncherTab::Cd => "CD", LauncherTab::Serial => "Serial", + LauncherTab::Parallel => "Parallel", LauncherTab::Zorro => "Zorro", LauncherTab::AvEmulation => "A/V & Emu", } @@ -282,6 +285,10 @@ pub enum LauncherField { RealtimePriority, Warp, Joystick, + // Parallel port + ParallelDevice, + SamplerInput, + SamplerGain, } /// How a row's value is edited, and therefore which widget the panel draws. @@ -371,11 +378,26 @@ const CD_ROWS: [Row; 3] = [ row(F::Cd32Nvram, "CD32 NVRAM", PathRow), ]; #[cfg(feature = "midi")] -const SERIAL_ROWS: [Row; 3] = [ +// Like the Parallel tab, the Serial tab is dynamic: the MIDI endpoint pickers +// appear only in MIDI mode rather than being greyed out in the other modes. +// Serial fields exist only in the `midi` build (the tab is midi-only). +#[cfg(feature = "midi")] +const SERIAL_ROWS_BASE: [Row; 1] = [row(F::SerialMode, "Mode", Cycle)]; +#[cfg(feature = "midi")] +const SERIAL_ROWS_MIDI: [Row; 3] = [ row(F::SerialMode, "Mode", Cycle), row(F::MidiIn, "MIDI input", Cycle), row(F::MidiOut, "MIDI output", Cycle), ]; +// The Parallel tab is dynamic: with no device connected only the picker shows; +// selecting Sampler reveals its options (they are not shown at all otherwise, so +// a future device's unrelated options never clutter this tab -- see `rows`). +const PARALLEL_ROWS_NONE: [Row; 1] = [row(F::ParallelDevice, "Device", Cycle)]; +const PARALLEL_ROWS_SAMPLER: [Row; 3] = [ + row(F::ParallelDevice, "Device", Cycle), + row(F::SamplerInput, "Audio input", Cycle), + row(F::SamplerGain, "Input gain", Cycle), +]; const AV_EMULATION_ROWS: [Row; 13] = [ row(F::AudioDevice, "Audio output", Cycle), row(F::AudioChannelMode, "Channel mode", Cycle), @@ -393,8 +415,14 @@ const AV_EMULATION_ROWS: [Row; 13] = [ ]; /// The rows shown on a tab, top to bottom. The `Zorro` tab has no rows: it is -/// drawn as a board list with Add/Remove controls (see the panel code). -pub fn rows(tab: LauncherTab) -> &'static [Row] { +/// drawn as a board list with Add/Remove controls (see the panel code). The +/// `Serial` and `Parallel` tabs are dynamic on the selected mode/device, so a +/// mode's options only appear when it is selected; other tabs ignore those args. +pub fn rows( + tab: LauncherTab, + parallel_device: ParallelDevice, + serial_mode: SerialMode, +) -> &'static [Row] { match tab { LauncherTab::System => &SYSTEM_ROWS, LauncherTab::Cpu => &CPU_ROWS, @@ -403,18 +431,28 @@ pub fn rows(tab: LauncherTab) -> &'static [Row] { LauncherTab::Floppy => &FLOPPY_ROWS, LauncherTab::Storage => &STORAGE_ROWS, LauncherTab::Cd => &CD_ROWS, - LauncherTab::Serial => serial_rows(), + LauncherTab::Serial => serial_rows(serial_mode), + LauncherTab::Parallel => match parallel_device { + ParallelDevice::Sampler => &PARALLEL_ROWS_SAMPLER, + ParallelDevice::None => &PARALLEL_ROWS_NONE, + }, LauncherTab::Zorro => &[], LauncherTab::AvEmulation => &AV_EMULATION_ROWS, } } -/// Serial-tab rows. Only the `midi` build has any; without it the tab is absent -/// from [`TABS`] and this is never reached. -fn serial_rows() -> &'static [Row] { +/// Serial-tab rows: just the mode picker, plus the MIDI endpoint pickers in MIDI +/// mode. Only the `midi` build has any; without it the tab is absent from +/// [`TABS`] and this is never reached. +fn serial_rows(serial_mode: SerialMode) -> &'static [Row] { + let _ = serial_mode; #[cfg(feature = "midi")] { - &SERIAL_ROWS + if serial_mode == SerialMode::Midi { + &SERIAL_ROWS_MIDI + } else { + &SERIAL_ROWS_BASE + } } #[cfg(not(feature = "midi"))] { @@ -510,6 +548,23 @@ const SERIAL_MODES: [SerialMode; 4] = [ /// The config/CLI accept any 0-100; an off-grid value snaps to the nearest here. const STEREO_SEPARATION_STEPS: [usize; 11] = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; +/// Sampler input-gain steps the picker cycles: -12 to +24 dB in 3 dB, stored as +/// the linear multiplier the sampler applies (the label shows dB). The config/ +/// CLI accept any positive gain; an off-grid value snaps to the nearest here. +const SAMPLER_GAIN_STEPS: [f64; 13] = [ + 0.251, 0.355, 0.501, 0.708, 1.0, 1.413, 1.995, 2.818, 3.981, 5.623, 7.943, 11.220, 15.849, +]; + +/// The dB label for a linear gain, e.g. `1.0 -> "0 dB"`, `2.0 -> "+6 dB"`. +fn gain_db_label(gain: f32) -> String { + let db = (20.0 * gain.max(f32::MIN_POSITIVE).log10()).round() as i32; + if db == 0 { + "0 dB".to_string() + } else { + format!("{db:+} dB") + } +} + /// A fully-typed, editable mirror of a configurable machine. See the module /// docs for how it round-trips through [`RawConfig`]. #[derive(Debug, Clone)] @@ -583,6 +638,14 @@ pub struct MachineSetup { audio_channel_mode: ChannelMode, /// Stereo width, 0-100 (100 = full hardware panning). audio_stereo_separation: u8, + /// Parallel-port device (None or Sampler). Drives which Parallel-tab rows show. + parallel_device: ParallelDevice, + /// Sampler host capture device (`None` = default), and the picker list + /// (filled on open / re-read on cycle, like the audio output picker). + sampler_input: Option, + sampler_input_devices: Vec, + /// Sampler input gain (linear multiplier), shown/cycled in dB steps. + sampler_gain: f32, overscan: Overscan, pixel_aspect: PixelAspect, phosphor: f32, @@ -674,6 +737,11 @@ impl MachineSetup { audio_devices: Vec::new(), audio_channel_mode: cfg.audio.channel_mode, audio_stereo_separation: cfg.audio.stereo_separation, + parallel_device: cfg.parallel.device, + sampler_input: cfg.parallel.sampler_input.clone(), + // Filled by refresh_sampler_inputs on open, like the audio devices. + sampler_input_devices: Vec::new(), + sampler_gain: cfg.parallel.sampler_gain, overscan: cfg.overscan, pixel_aspect: cfg.pixel_aspect, phosphor: cfg.phosphor, @@ -718,14 +786,30 @@ impl MachineSetup { self.audio_devices = crate::audio::picker_output_devices(); } - /// Re-read every host device list (MIDI endpoints + audio outputs) for the - /// pickers. Call after (re)building the setup -- e.g. loading a config or - /// resetting to defaults -- so the pickers show what is connected now - /// instead of an empty list that can only land on "Default"/"None". + pub fn refresh_sampler_inputs(&mut self) { + self.sampler_input_devices = crate::sampler::picker_input_devices(); + } + + /// The parallel-port device, so the panel knows which Parallel-tab rows to + /// show (see [`rows`]). + pub fn parallel_device(&self) -> ParallelDevice { + self.parallel_device + } + + /// The serial mode, so the panel knows whether to show the MIDI-tab pickers. + pub fn serial_mode(&self) -> SerialMode { + self.serial_mode + } + + /// Re-read every host device list (MIDI endpoints + audio outputs + sampler + /// inputs) for the pickers. Call after (re)building the setup -- e.g. loading + /// a config or resetting to defaults -- so the pickers show what is connected + /// now instead of an empty list that can only land on "Default"/"None". pub fn refresh_host_devices(&mut self) { #[cfg(feature = "midi")] self.refresh_midi_endpoints(); self.refresh_audio_devices(); + self.refresh_sampler_inputs(); } /// The bare-profile config this setup is compared against when emitting @@ -902,6 +986,13 @@ impl MachineSetup { .then(|| self.audio_channel_mode.label().to_string()); raw.audio.stereo_separation = (self.audio_stereo_separation != 100) .then_some(u16::from(self.audio_stereo_separation)); + // Parallel port. Emit the device only when a sampler is selected (None is + // the default), and the sampler sub-options only then, minimally. + if self.parallel_device == ParallelDevice::Sampler { + raw.parallel.device = Some(ParallelDevice::Sampler.label().to_string()); + raw.parallel.sampler_input = self.sampler_input.clone(); + raw.parallel.sampler_gain = (self.sampler_gain != 1.0).then_some(self.sampler_gain); + } // Zorro boards: emit the metadata path plus any per-board overrides // (typed per the option schema), only when the user changed something. raw.zorro = self @@ -1044,8 +1135,8 @@ impl MachineSetup { F::Df1Image | F::Df1WriteProtect => reason(self.floppy_drives >= 2, "drive off"), F::Df2Image | F::Df2WriteProtect => reason(self.floppy_drives >= 3, "drive off"), F::Df3Image | F::Df3WriteProtect => reason(self.floppy_drives >= 4, "drive off"), - #[cfg(feature = "midi")] - F::MidiOut | F::MidiIn => reason(self.serial_mode == SerialMode::Midi, "MIDI off"), + // MIDI in/out are not greyed off-MIDI-mode: the Serial tab hides them + // entirely then (see `rows`), like the Parallel tab's sampler rows. // Channel mode and separation shape the output, so they do nothing // once audio is disabled; separation also does nothing in mono. F::AudioChannelMode => reason(self.audio_output.is_enabled(), "off"), @@ -1231,6 +1322,15 @@ impl MachineSetup { ChannelMode::Mono => "Mono".to_string(), }, F::AudioStereoSeparation => format!("{}%", self.audio_stereo_separation), + F::ParallelDevice => match self.parallel_device { + ParallelDevice::None => "None".to_string(), + ParallelDevice::Sampler => "Sampler".to_string(), + }, + F::SamplerInput => self + .sampler_input + .clone() + .unwrap_or_else(|| "Default".to_string()), + F::SamplerGain => gain_db_label(self.sampler_gain), // Path/drive fields: the file name, or a placeholder. F::Rom => self.path_label(field, "(bundled AROS)"), _ if rows_contains_kind(field, RowKind::Path) @@ -1336,6 +1436,28 @@ impl MachineSetup { forward, ) as u8 } + F::ParallelDevice => { + self.parallel_device = match self.parallel_device { + ParallelDevice::None => ParallelDevice::Sampler, + ParallelDevice::Sampler => ParallelDevice::None, + }; + // Populate the input picker as the sampler options appear. + if self.parallel_device == ParallelDevice::Sampler { + self.refresh_sampler_inputs(); + } + } + F::SamplerInput => { + self.refresh_sampler_inputs(); + self.sampler_input = next_device_or_default( + self.sampler_input.as_deref(), + &self.sampler_input_devices, + forward, + ); + } + F::SamplerGain => { + self.sampler_gain = + cycle_floats(&SAMPLER_GAIN_STEPS, f64::from(self.sampler_gain), forward) as f32 + } _ => {} } } @@ -1615,8 +1737,10 @@ fn cycle_endpoint( /// Whether `field` appears anywhere with the given row kind. Used to classify a /// field (toggle vs path) without threading the tab through every call. fn rows_contains_kind(field: LauncherField, kind: RowKind) -> bool { + // Classify against the maximal row set, so the Serial/Parallel tabs' + // mode-gated rows are all visible here regardless of the current selection. TABS.iter() - .flat_map(|&t| rows(t)) + .flat_map(|&t| rows(t, ParallelDevice::Sampler, SerialMode::Midi)) .any(|r| r.field == field && r.kind == kind) } @@ -1661,6 +1785,25 @@ fn cycle_floats(items: &[f64], current: f64, forward: bool) -> f64 { items[next] } +/// Cycle an optional device selection through "Default" (`None`) then the named +/// devices and back -- the sampler input picker (mirrors the MIDI/audio pickers). +fn next_device_or_default( + current: Option<&str>, + names: &[String], + forward: bool, +) -> Option { + let here = current + .and_then(|c| names.iter().position(|n| n == c)) + .map_or(0, |i| i + 1); + let count = names.len() + 1; + let next = if forward { + (here + 1) % count + } else { + (here + count - 1) % count + }; + (next > 0).then(|| names[next - 1].clone()) +} + /// Cycle through `usize` size presets, snapping a loaded off-grid value to the /// nearest preset before stepping. fn cycle_nearest(items: &[usize], current: usize, forward: bool) -> usize { @@ -2138,6 +2281,46 @@ mod tests { ); } + #[test] + fn parallel_sampler_rows_appear_only_when_selected() { + let mut s = MachineSetup::default(); + // None (default): only the device picker row; nothing emitted. + assert_eq!( + rows( + LauncherTab::Parallel, + ParallelDevice::None, + SerialMode::Stdout + ) + .len(), + 1 + ); + assert_eq!(s.value_label(LauncherField::ParallelDevice), "None"); + assert_eq!(s.to_raw().parallel.device, None); + + // Sampler: its four rows appear and it round-trips. + assert_eq!( + rows( + LauncherTab::Parallel, + ParallelDevice::Sampler, + SerialMode::Stdout + ) + .len(), + 3 + ); + s.parallel_device = ParallelDevice::Sampler; + assert_eq!(s.value_label(LauncherField::ParallelDevice), "Sampler"); + // Default gain reads as 0 dB. + assert_eq!(s.value_label(LauncherField::SamplerGain), "0 dB"); + let raw = s.to_raw(); + assert_eq!(raw.parallel.device.as_deref(), Some("sampler")); + + // +6 dB gain round-trips and rounds to a dB label. + s.sampler_gain = 1.995; + assert_eq!(s.value_label(LauncherField::SamplerGain), "+6 dB"); + let raw = s.to_raw(); + assert_eq!(raw.parallel.sampler_gain, Some(1.995)); + } + #[test] fn audio_output_disabled_round_trips_through_raw_config() { use crate::audio::AudioOutput; @@ -2164,10 +2347,22 @@ mod tests { #[cfg(feature = "midi")] #[test] - fn midi_device_rows_are_disabled_off_midi_mode() { - let s = MachineSetup::default(); // Stdout by default - assert!(s.disabled_reason(LauncherField::MidiOut).is_some()); - assert!(s.disabled_reason(LauncherField::MidiIn).is_some()); + fn midi_device_rows_appear_only_in_midi_mode() { + // Off-MIDI (Stdout default): only the Mode row shows. + assert_eq!( + rows( + LauncherTab::Serial, + ParallelDevice::None, + SerialMode::Stdout + ) + .len(), + 1 + ); + // MIDI mode: the in/out pickers appear too. + let midi = rows(LauncherTab::Serial, ParallelDevice::None, SerialMode::Midi); + assert_eq!(midi.len(), 3); + assert!(midi.iter().any(|r| r.field == LauncherField::MidiIn)); + assert!(midi.iter().any(|r| r.field == LauncherField::MidiOut)); } #[test] diff --git a/src/video/ui.rs b/src/video/ui.rs index 87abfdf..83e056f 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -83,6 +83,10 @@ pub enum MenuItem { MidiInput, #[cfg(feature = "midi")] MidiOutput, + /// Parallel-port sampler live controls; present only when a sampler is + /// attached (see `menu_items`). + SamplerInput, + SamplerGain, PixelAspect, AudioOutput, Warp, @@ -95,13 +99,14 @@ pub enum MenuItem { MachineConfig, } -/// The menu items, top to bottom. The MIDI device items appear only when the -/// serial port is in MIDI mode, so the list is built per open rather than fixed. -pub fn menu_items(midi_active: bool) -> Vec { +/// The menu items, top to bottom. The MIDI device items appear only in MIDI +/// mode, and the sampler items only when a sampler is attached, so the list is +/// built per open rather than fixed. +pub fn menu_items(midi_active: bool, sampler_active: bool) -> Vec { let _ = midi_active; - // 7 leading + up to 2 MIDI + 10 trailing items, sized so appending never - // reallocates. - let mut items = Vec::with_capacity(19); + // 7 leading + up to 2 MIDI + up to 3 sampler + 10 trailing items, sized so + // appending never reallocates. + let mut items = Vec::with_capacity(22); items.extend([ MenuItem::MachineConfig, MenuItem::FrameAnalyzer, @@ -116,6 +121,11 @@ pub fn menu_items(midi_active: bool) -> Vec { items.push(MenuItem::MidiInput); items.push(MenuItem::MidiOutput); } + // Sampler live controls, below the MIDI items (or above Pixel Aspect). + if sampler_active { + items.push(MenuItem::SamplerInput); + items.push(MenuItem::SamplerGain); + } items.push(MenuItem::PixelAspect); items.extend([ MenuItem::Warp, @@ -148,6 +158,10 @@ pub struct MenuLabels<'a> { /// Current audio output label: "Default", a device name, or "Disabled" /// (empty is treated as "Default"). pub audio_output: &'a str, + /// Sampler live-control labels (only shown when a sampler is attached): the + /// input device ("Default" or a name) and the gain in dB. + pub sampler_input: &'a str, + pub sampler_gain: &'a str, } fn menu_item_label(item: MenuItem, s: MenuLabels) -> String { @@ -170,6 +184,15 @@ fn menu_item_label(item: MenuItem, s: MenuLabels) -> String { MenuItem::MidiInput => format!("MIDI In [{}]", clip_menu_value(s.midi_in)), #[cfg(feature = "midi")] MenuItem::MidiOutput => format!("MIDI Out [{}]", clip_menu_value(s.midi_out)), + MenuItem::SamplerInput => { + let name = if s.sampler_input.is_empty() { + "Default" + } else { + s.sampler_input + }; + format!("Sampler In [{}]", clip_menu_value(name)) + } + MenuItem::SamplerGain => format!("Sampler Gain [{}]", s.sampler_gain), MenuItem::AudioOutput => { let name = if s.audio_output.is_empty() { "Default" @@ -199,10 +222,10 @@ fn menu_item_label(item: MenuItem, s: MenuLabels) -> String { } } -/// Clip a device name so a "MIDI Out [name]" / "Audio Out [name]" label stays -/// within the popup. +/// Clip a device name so a "MIDI Out [name]" / "Audio Out [name]" / "Sampler In +/// [name]" label stays within the popup. fn clip_menu_value(name: &str) -> String { - const MAX: usize = MENU_MAX_LABEL_CHARS - 12; // widest prefix "Audio Out [" plus "]" + const MAX: usize = MENU_MAX_LABEL_CHARS - 14; // widest prefix "Sampler In [" plus "]" if name.chars().count() <= MAX { return name.to_string(); } @@ -509,9 +532,14 @@ impl UiState { /// The UI control under `pos`, if any. `midi_active` selects the same menu /// item list the draw uses. `PanelBody` swallows clicks on a panel's /// background so they never reach the emulated display. - pub fn control_at(&self, pos: (i32, i32), midi_active: bool) -> Option { + pub fn control_at( + &self, + pos: (i32, i32), + midi_active: bool, + sampler_active: bool, + ) -> Option { if self.menu_open { - let items = menu_items(midi_active); + let items = menu_items(midi_active, sampler_active); for (index, item) in items.iter().enumerate() { if menu_item_rect(index, items.len()).contains(pos) { return Some(UiControl::MenuItem(*item)); @@ -770,7 +798,7 @@ pub enum UiControl { fn panel_dims(panel: &Panel) -> (usize, usize) { match panel { Panel::About => (560, 380), - Panel::Shortcuts => (600, 440), + Panel::Shortcuts => (600, 462), Panel::Calibration(_) => (620, 372), Panel::Debugger(_) => (684, 520), Panel::FrameAnalyzer(_) => (700, 526), @@ -1547,10 +1575,11 @@ fn draw_menu( frame: &mut [u8], hover: Option, midi_active: bool, + sampler_active: bool, labels: MenuLabels, scale: usize, ) { - let items = menu_items(midi_active); + let items = menu_items(midi_active, sampler_active); let rect = menu_rect(items.len()); let scaled = scale_rect(rect, scale); fill_rect(frame, scaled, MENU_BG, scale); @@ -1653,7 +1682,7 @@ fn draw_about(frame: &mut [u8], rect: Rect, view: &AboutView, scale: usize) { } } -const SHORTCUT_ROWS: [(&str, &str, bool); 16] = [ +const SHORTCUT_ROWS: [(&str, &str, bool); 17] = [ ("Q", "Quit", true), ("S", "Save screenshot", true), ("R", "Record video on/off", true), @@ -1668,6 +1697,7 @@ const SHORTCUT_ROWS: [(&str, &str, bool); 16] = [ ("Shift+A", "Cycle audio output", true), ("W", "Warp speed on/off", true), ("Shift+W", "Warp limit (2x..Max)", true), + ("Shift +/-", "Sampler gain (when attached)", true), ("Esc", "Close menu/window", false), ("Ctrl+Ami+Ami", "Keyboard reset", false), ]; @@ -3519,7 +3549,14 @@ fn launcher_control_at(rect: Rect, state: &LauncherState, pos: (i32, i32)) -> Op return Some(UiControl::LauncherZorroAdd); } } else { - for (i, r) in launcher::rows(state.tab).iter().enumerate() { + for (i, r) in launcher::rows( + state.tab, + state.setup.parallel_device(), + state.setup.serial_mode(), + ) + .iter() + .enumerate() + { if !state.setup.applies(r.field) { continue; } @@ -4070,7 +4107,14 @@ fn draw_launcher( if state.tab == LauncherTab::Zorro { draw_launcher_zorro(frame, rect, state, hover, scale); } else { - for (i, r) in launcher::rows(state.tab).iter().enumerate() { + for (i, r) in launcher::rows( + state.tab, + state.setup.parallel_device(), + state.setup.serial_mode(), + ) + .iter() + .enumerate() + { draw_launcher_row(frame, rect, state, r, i, hover, scale); } } @@ -4145,13 +4189,21 @@ pub fn draw( hover: Option, data: Option<&PanelViewData>, midi_active: bool, + sampler_active: bool, labels: MenuLabels, ) { if let Some(panel) = &ui.panel { draw_panel_layer(frame, texture_scale, panel, hover, data); } if ui.menu_open { - draw_menu(frame, hover, midi_active, labels, texture_scale); + draw_menu( + frame, + hover, + midi_active, + sampler_active, + labels, + texture_scale, + ); } } @@ -4385,7 +4437,7 @@ mod tests { #[test] fn menu_sits_above_the_status_bar_and_hit_tests_items() { - let n = menu_items(false).len(); + let n = menu_items(false, false).len(); let rect = menu_rect(n); assert!(rect.y + rect.h <= present_height()); assert!(rect.x + rect.w <= FB_WIDTH); @@ -4397,7 +4449,7 @@ mod tests { let first = menu_item_rect(0, n); let pos = (first.x as i32 + 4, first.y as i32 + 4); assert_eq!( - ui.control_at(pos, false), + ui.control_at(pos, false, false), Some(UiControl::MenuItem(MenuItem::MachineConfig)) ); // Leading block is MachineConfig, FrameAnalyzer, Debugger, Console, @@ -4405,18 +4457,18 @@ mod tests { let joystick = menu_item_rect(6, n); let pos = (joystick.x as i32 + 4, joystick.y as i32 + 4); assert_eq!( - ui.control_at(pos, false), + ui.control_at(pos, false, false), Some(UiControl::MenuItem(MenuItem::JoystickInput)) ); // Outside the menu: nothing (the click closes the menu). - assert_eq!(ui.control_at((0, 0), false), None); + assert_eq!(ui.control_at((0, 0), false, false), None); } #[test] fn every_menu_label_fits_inside_the_popup() { // The label is drawn at `item_rect.x + MENU_TEXT_INSET`; its glyphs // must end before the popup's right edge or the trailing "~" clips. - let items = menu_items(true); + let items = menu_items(true, true); let menu = menu_rect(items.len()); let limit = menu.x + menu.w; let modes = [JoystickInputMode::Gamepad, JoystickInputMode::Keyboard]; @@ -4441,6 +4493,8 @@ mod tests { midi_in: long, midi_out: long, audio_output: long, + sampler_input: long, + sampler_gain: "+24 dB", }; let label = menu_item_label(item, labels); let text_w = @@ -4472,6 +4526,7 @@ mod tests { assert_eq!( ui.control_at( (raster.x as i32 + raster.w as i32 / 2, raster.y as i32 + 2), + false, false ), Some(UiControl::AnalyzerPick { @@ -4487,6 +4542,7 @@ mod tests { scanline.x as i32 + scanline.w as i32 / 2, scanline.y as i32 + 2 ), + false, false ), Some(UiControl::AnalyzerPick { @@ -4498,12 +4554,12 @@ mod tests { let (control, button) = analyzer_button_rects(rect)[1]; assert_eq!(control, UiControl::AnalyzerFrame); assert_eq!( - ui.control_at((button.x as i32 + 2, button.y as i32 + 2), false), + ui.control_at((button.x as i32 + 2, button.y as i32 + 2), false, false), Some(UiControl::AnalyzerFrame) ); let underlay = analyzer_underlay_rect(rect); assert_eq!( - ui.control_at((underlay.x as i32 + 2, underlay.y as i32 + 2), false), + ui.control_at((underlay.x as i32 + 2, underlay.y as i32 + 2), false, false), Some(UiControl::AnalyzerUnderlay) ); // The checkbox must not overlap the transport buttons. @@ -4627,12 +4683,18 @@ mod tests { let rect = panel_rect(ui.panel.as_ref().unwrap()); let close = close_button_rect(rect); let pos = (close.x as i32 + 2, close.y as i32 + 2); - assert_eq!(ui.control_at(pos, false), Some(UiControl::PanelClose)); + assert_eq!( + ui.control_at(pos, false, false), + Some(UiControl::PanelClose) + ); // Panel body swallows clicks. let body = (rect.x as i32 + 5, (rect.y + TITLE_H + 5) as i32); - assert_eq!(ui.control_at(body, false), Some(UiControl::PanelBody)); + assert_eq!( + ui.control_at(body, false, false), + Some(UiControl::PanelBody) + ); // Outside the panel: nothing. - assert_eq!(ui.control_at((0, 0), false), None); + assert_eq!(ui.control_at((0, 0), false, false), None); } #[test] @@ -4644,22 +4706,22 @@ mod tests { let rect = panel_rect(ui.panel.as_ref().unwrap()); let tab = debug_tab_rect(rect, 3); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false, false), Some(UiControl::DebugTab(DebugTab::Video)) ); let tab = debug_tab_rect(rect, 4); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false, false), Some(UiControl::DebugTab(DebugTab::Audio)) ); let tab = debug_tab_rect(rect, 6); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false, false), Some(UiControl::DebugTab(DebugTab::IoMap)) ); let tab = debug_tab_rect(rect, 7); assert_eq!( - ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false), + ui.control_at((tab.x as i32 + 2, tab.y as i32 + 2), false, false), Some(UiControl::DebugTab(DebugTab::Break)) ); // All eight tabs fit inside the panel. @@ -4668,7 +4730,7 @@ mod tests { let (control, step) = debug_button_rects(rect)[1]; assert_eq!(control, UiControl::DebugStep); assert_eq!( - ui.control_at((step.x as i32 + 2, step.y as i32 + 2), false), + ui.control_at((step.x as i32 + 2, step.y as i32 + 2), false, false), Some(UiControl::DebugStep) ); @@ -4683,11 +4745,11 @@ mod tests { assert_eq!(control, UiControl::DebugBreakToggle); let pos = (toggle.x as i32 + 2, toggle.y as i32 + 2); assert_eq!( - ui_break.control_at(pos, false), + ui_break.control_at(pos, false, false), Some(UiControl::DebugBreakToggle) ); // On another tab the same position is just panel body. - assert_eq!(ui.control_at(pos, false), Some(UiControl::PanelBody)); + assert_eq!(ui.control_at(pos, false, false), Some(UiControl::PanelBody)); // Audio-tab mute buttons hit-test only while the Audio tab is active. let mut panel = DebuggerPanel::new(); @@ -4700,7 +4762,7 @@ mod tests { assert_eq!(control, UiControl::DebugAudioMute(0)); let pos = (mute0.x as i32 + 2, mute0.y as i32 + 2); assert_eq!( - ui_audio.control_at(pos, false), + ui_audio.control_at(pos, false, false), Some(UiControl::DebugAudioMute(0)) ); // The CD mute is the fifth (index 4) button. @@ -4708,11 +4770,11 @@ mod tests { assert_eq!(cd_control, UiControl::DebugAudioMute(4)); let cd_pos = (cd_mute.x as i32 + 2, cd_mute.y as i32 + 2); assert_eq!( - ui_audio.control_at(cd_pos, false), + ui_audio.control_at(cd_pos, false, false), Some(UiControl::DebugAudioMute(4)) ); // On another tab that position does not resolve to a mute. - assert_eq!(ui.control_at(pos, false), Some(UiControl::PanelBody)); + assert_eq!(ui.control_at(pos, false, false), Some(UiControl::PanelBody)); let mut panel = DebuggerPanel::new(); for ch in ['c', '0', '0', '3', 'C'] { @@ -4967,6 +5029,7 @@ mod tests { None, None, false, + false, MenuLabels { warp: true, warp_speed: WarpSpeed::Max, @@ -4977,9 +5040,11 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); - let menu = menu_rect(menu_items(false).len()); + let menu = menu_rect(menu_items(false, false).len()); let probe = ((menu.y + MENU_PAD + 2) * w + menu.x + 4) * 4; assert_eq!(&frame[probe..probe + 4], &MENU_BG.to_le_bytes()); save(&frame, "menu"); @@ -5006,6 +5071,7 @@ mod tests { None, Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5016,6 +5082,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5033,6 +5101,7 @@ mod tests { None, Some(&PanelViewData::Shortcuts), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5043,6 +5112,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5077,6 +5148,7 @@ mod tests { Some(UiControl::CalCancel), Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5087,6 +5159,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5131,6 +5205,7 @@ mod tests { Some(UiControl::DebugStep), Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5141,6 +5216,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5182,6 +5259,7 @@ mod tests { Some(UiControl::DebugRegToggle), Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5192,6 +5270,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5285,6 +5365,7 @@ mod tests { Some(UiControl::DebugAudioMute(0)), Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5295,6 +5376,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5350,6 +5433,7 @@ mod tests { None, Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5360,6 +5444,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5426,6 +5512,7 @@ mod tests { Some(UiControl::DebugPlaneToggle(0)), Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5436,6 +5523,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5539,6 +5628,7 @@ mod tests { Some(UiControl::AnalyzerUnderlay), Some(&data), false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5549,6 +5639,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5582,6 +5674,7 @@ mod tests { None, None, false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5592,6 +5685,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5616,6 +5711,7 @@ mod tests { Some(UiControl::LauncherRun), None, false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5626,6 +5722,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5690,6 +5788,7 @@ mod tests { None, None, false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5700,6 +5799,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); @@ -5731,6 +5832,7 @@ mod tests { None, None, false, + false, MenuLabels { warp: false, warp_speed: WarpSpeed::Max, @@ -5741,6 +5843,8 @@ mod tests { midi_in: "", midi_out: "", audio_output: "", + sampler_input: "", + sampler_gain: "", }, ); assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); diff --git a/src/video/window.rs b/src/video/window.rs index 7de6027..e3d878a 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -364,6 +364,16 @@ fn host_shortcut_modifier_pressed(modifiers: ModifiersState) -> bool { } } +/// The dB label for a linear sampler gain, e.g. `1.0 -> "0 dB"`, `2.0 -> "+6 dB"`. +fn sampler_gain_db_label(gain: f32) -> String { + let db = (20.0 * gain.max(f32::MIN_POSITIVE).log10()).round() as i32; + if db == 0 { + "0 dB".to_string() + } else { + format!("{db:+} dB") + } +} + /// Display name for a GDB-style register index (see `debug_set_register`): /// D0-D7, A0-A7, SR, PC. fn gdb_reg_label(reg: usize) -> String { @@ -613,6 +623,10 @@ pub struct App { /// setting: the config-screen launcher rebuilds the machine config from its /// own fields, so this is held here rather than read back from that config. audio_output: crate::audio::AudioOutput, + /// The parallel-port audio sampler request (`--parallel sampler`). Held here + /// so every machine started from this session -- including from the config + /// screen -- gets it re-attached, since the launcher rebuilds the machine. + sampler: crate::sampler::SamplerRequest, /// The session's `[emulation] realtime_priority` request (config value, before /// the env override). Re-fed to `priority::requested` whenever the audio sink /// is rebuilt live (device switch, disconnect recovery, post-load install) so @@ -848,6 +862,9 @@ impl App { // caller's --audio/--noaudio-resolved value; for the config-screen // placeholder the config intent (so a state loaded over it gets sound). audio_output_enabled: bool, + // Parallel-port sampler request, so machines started from the config + // screen also get it attached. + sampler: crate::sampler::SamplerRequest, ) -> Self { // Headless capture runs drive themselves off emulated time, so a // powered-off start would simply hang. Force power on for those. @@ -885,6 +902,7 @@ impl App { emu, serial_is_midi, audio_output, + sampler, realtime_priority, fb: vec![0u32; MAX_FB_PIXELS], deinterlacer: Deinterlacer::with_phosphor(phosphor), @@ -1086,6 +1104,42 @@ impl App { self.show_osd(format!("Audio output: {}", self.audio_output.label())); } + /// Cycle the sampler's host capture device live (Default -> host inputs), + /// rebuilding the sampler so the change takes effect at once. Only reachable + /// when a sampler is attached (the menu item is present only then). + fn cycle_sampler_input(&mut self) { + let devices = crate::sampler::picker_input_devices(); + self.sampler.input_device = + crate::sampler::next_input_device(self.sampler.input_device.as_deref(), &devices, true); + self.attach_session_sampler(); + let label = self.sampler.input_device.as_deref().unwrap_or("Default"); + self.show_osd(format!("Sampler input: {label}")); + } + + /// Step the sampler input gain by `delta_db` (3 dB steps) live, rebuilding + /// the sampler. `wrap` cycles the +24/-12 dB ends (the menu item); otherwise + /// they clamp (the +/- shortcuts). No-op without a sampler attached. + fn step_sampler_gain(&mut self, delta_db: f32, wrap: bool) { + if !self.emu.bus().has_parallel_device() { + return; + } + let cur_db = (20.0 * self.sampler.gain.max(f32::MIN_POSITIVE).log10() / 3.0).round() * 3.0; + let target = cur_db + delta_db; + let next_db = if wrap && target > 24.0 { + -12.0 + } else if wrap && target < -12.0 { + 24.0 + } else { + target.clamp(-12.0, 24.0) + }; + self.sampler.gain = 10f32.powf(next_db / 20.0); + self.attach_session_sampler(); + self.show_osd(format!( + "Sampler gain: {}", + sampler_gain_db_label(self.sampler.gain) + )); + } + fn set_joystick_input_mode(&mut self, mode: JoystickInputMode) { if self.joystick_input_mode == mode { return; @@ -1492,6 +1546,15 @@ impl ApplicationHandler for App { self.cycle_audio_output() } } + // Sampler input gain: Cmd/Alt+Shift and the +/- keys (the + // `=` and `-` keys). No-op unless a sampler is attached. + (KeyCode::Equal | KeyCode::Minus, ElementState::Pressed) + if host_shortcut_modifier_pressed(self.modifiers) + && self.modifiers.shift_key() => + { + let delta = if code == KeyCode::Minus { -3.0 } else { 3.0 }; + self.step_sampler_gain(delta, false); + } (other, state) => { let pressed = state == ElementState::Pressed; if pressed && self.ui_handle_key(other) { @@ -1724,6 +1787,13 @@ impl ApplicationHandler for App { } else { (String::new(), String::new()) }; + let sampler_active = self.emu.bus().has_parallel_device(); + let sampler_input_label = self + .sampler + .input_device + .clone() + .unwrap_or_else(|| "Default".to_string()); + let sampler_gain_label = sampler_gain_db_label(self.sampler.gain); let ui_data = self.build_panel_view_data(); if let Some(r) = self.render.as_mut() { let frame = r.pixels.frame_mut(); @@ -1751,6 +1821,7 @@ impl ApplicationHandler for App { ui_hover, ui_data.as_ref(), self.serial_is_midi, + sampler_active, ui::MenuLabels { warp, warp_speed, @@ -1761,6 +1832,8 @@ impl ApplicationHandler for App { midi_in: &midi_in_label, midi_out: &midi_out_label, audio_output: self.audio_output.label(), + sampler_input: &sampler_input_label, + sampler_gain: &sampler_gain_label, }, ); if let Err(e) = r.pixels.render() { @@ -2366,7 +2439,11 @@ impl App { if self.ui.panel.is_none() && self.tool_panel_open() && !self.ui.menu_open { return None; } - self.ui.control_at(pos, self.serial_is_midi) + self.ui.control_at( + pos, + self.serial_is_midi, + self.emu.bus().has_parallel_device(), + ) } fn main_ui_hover_changed( @@ -2747,6 +2824,8 @@ impl App { ui::MenuItem::MidiOutput => self.cycle_midi_output(), ui::MenuItem::PixelAspect => self.toggle_pixel_aspect(), ui::MenuItem::AudioOutput => self.cycle_audio_output(), + ui::MenuItem::SamplerInput => self.cycle_sampler_input(), + ui::MenuItem::SamplerGain => self.step_sampler_gain(3.0, true), ui::MenuItem::Warp => self.toggle_warp(), ui::MenuItem::WarpLimit => self.cycle_warp_speed(), ui::MenuItem::Record => self.toggle_recording(), @@ -3829,6 +3908,9 @@ impl App { cfg.audio.output_enabled, cfg.audio.output_device.as_deref(), ); + // The launcher's Parallel section drives the sampler; run_machine + // re-attaches it from this. + self.sampler = crate::sampler::SamplerRequest::from_config(&cfg.parallel); let audio: Box = match crate::audio::open_output_sink(realtime, &self.audio_output) { Ok(sink) => sink, @@ -3862,6 +3944,9 @@ impl App { /// audio sink, are dropped here. fn run_machine(&mut self, emu: Emulator, cfg: &Config, raw: RawConfig) { self.emu = emu; + // Re-attach the parallel-port sampler: the launcher builds a fresh + // machine, so a sampler requested at startup would otherwise be lost. + self.attach_session_sampler(); // The real machine may bridge serial to MIDI; the config-screen // placeholder never does, so recompute now that the machine is live. #[cfg(feature = "midi")] @@ -5914,6 +5999,22 @@ impl App { && self.emu.bus().paula.audio.is_null_sink() } + /// Build and attach the session's parallel-port sampler to the current + /// machine, opening a fresh host capture stream. A no-op unless a + /// sampler was requested; on failure it warns and runs without one. + fn attach_session_sampler(&mut self) { + if !self.sampler.enabled { + return; + } + match crate::sampler::CpalSampler::new( + self.sampler.input_device.as_deref(), + self.sampler.gain, + ) { + Ok(s) => self.emu.bus_mut().attach_parallel_device(Some(Box::new(s))), + Err(e) => warn!("sampler: could not attach parallel-port sampler: {e:#}"), + } + } + /// Replace the placeholder machine's silent NullSink with a live host audio /// output after a save state is loaded over the configuration screen. This /// mirrors the launcher Run path (`launcher_run`): the configuration screen diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 32e33cb..fba10ba 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -1664,6 +1664,7 @@ fn test_app_with_audio(audio: Box) -> super::App { vec!["Machine: test".to_string()], crate::config::RawConfig::default(), true, + crate::sampler::SamplerRequest::default(), ) }