parallel: audio sampler, on a general parallel-port framework#138
Conversation
6dc6eab to
f25668f
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a small, extensible parallel-port device framework and wires up its first device: an 8‑bit audio sampler (digitizer) that sources samples from a host audio input via cpal. It also integrates the new parallel/sampler functionality end-to-end across config parsing, CLI flags, the launcher UI, and the runtime menu/shortcuts.
Changes:
- Add a parallel-port device abstraction (
ParallelPortDevice) and bus hooks for CIA-A PRB reads/writes. - Implement a
cpal-backed parallel-port audio sampler device with input device selection and gain handling. - Expose the sampler via
[parallel]config, CLI options, and UI (launcher tab + runtime menu + shortcuts).
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/video/window/tests.rs | Updates window tests to pass the new sampler request through App construction. |
| src/video/window.rs | Adds sampler session state + runtime controls (menu + shortcuts) and (re)attachment logic. |
| src/video/ui.rs | Extends runtime menu plumbing and hit-testing to include sampler controls when active. |
| src/video/launcher.rs | Adds a Parallel tab with dynamic rows and sampler input/gain configuration support. |
| src/sampler.rs | New sampler device implementation: host capture ringbuffer + emulated-time consumption. |
| src/parallel.rs | New parallel-port device trait defining read/write hooks and device labeling. |
| src/main.rs | Adds CLI flags for parallel device selection and sampler input/gain + listing inputs. |
| src/lib.rs | Exposes new parallel and sampler modules. |
| src/config.rs | Adds [parallel] config model, raw serialization, parsing, and CLI override plumbing. |
| src/bus/tests.rs | Adds unit coverage proving CIA-A PRB reads route through an attached parallel device. |
| src/bus.rs | Adds optional parallel device storage and PRB read/write dispatch hooks (serde-skipped). |
| src/audio.rs | Makes ALSA filtering helpers usable by the sampler input picker/listing paths. |
| README.md | Documents the new parallel-port framework and sampler usage at a high level. |
| docs/guide/ui.md | Documents the sampler gain shortcut. |
| docs/guide/configuration.md | Documents [parallel] configuration options and equivalently-named CLI flags. |
| copperline.example.toml | Adds example [parallel] sampler configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 | ||
| } |
There was a problem hiding this comment.
Good spot, will clean that up
There was a problem hiding this comment.
Fixed — the ADC conversion is now * 128.0 + 128, so -full maps to 0, silence to 128, +full clamps to 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); |
There was a problem hiding this comment.
Updated — the test now asserts sample_to_byte(-1.0) == 0 and (1.0) == 255 to match the corrected conversion.
| } else { | ||
| (String::new(), String::new()) | ||
| }; | ||
| let sampler_active = self.emu.bus().has_parallel_device(); |
There was a problem hiding this comment.
Fixed — sampler_active is now self.sampler_stream.is_some() (an actually-attached sampler), so a future non-sampler parallel device won't show the sampler menu items.
| fn step_sampler_gain(&mut self, delta_db: f32, wrap: bool) { | ||
| if !self.emu.bus().has_parallel_device() { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Fixed — step_sampler_gain is now gated on the session sampler request (self.sampler.enabled), so it no-ops for a non-sampler device.
| // 7 leading + up to 2 MIDI + up to 3 sampler + 10 trailing items, sized so | ||
| // appending never reallocates. |
There was a problem hiding this comment.
Noted - was originally going to have an option for stereo/mono but realised it was a lot more complexity for such a niche feature.
There was a problem hiding this comment.
Fixed — the comment now reads "2 sampler" to match the two items pushed.
| // 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); | ||
| } |
There was a problem hiding this comment.
Fixed — to_raw now carries sampler_input/sampler_gain whenever they're set, so they round-trip through a Save even when the device is temporarily None. (They don't imply the sampler on reload; a bare output still implies the printer, which an explicit device disambiguates.)
| 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. |
There was a problem hiding this comment.
Similar to the above I ended up dropping channel mode
There was a problem hiding this comment.
Fixed — the sampler rows are now a 3-row set (PARALLEL_ROWS_SAMPLER: Device/Audio input/Input gain) with no stale count comment.
| self.ui.control_at( | ||
| pos, | ||
| self.serial_is_midi, | ||
| self.emu.bus().has_parallel_device(), | ||
| ) |
There was a problem hiding this comment.
Ack, will clean this up
There was a problem hiding this comment.
Fixed — control_at now uses self.sampler_stream.is_some(), the same condition as the menu draw path, so hit-testing and drawing agree.
Thanks, always bugged me it never worked properly in WinUAE/AmiBerry, and AFAIK none of the other Amiga emulators support it at all - Copperline is perfect for this sort of feature (same with MIDI). I do appreciate 99% of people who use this will probably be to play games / not make music, so wanted to implement in a way where someone could add other devices to the parallel port in the future. I'm in Greece for the next 10 days, and as per the promise to my girlfriend didn't bring my laptop lol, but I'll address the comments as soon as I'm back. |
Happy to support this kind of thing.
I want this to be for everyone, not just for gamers. I figured if I get things right, I can use it to prototype hardware and firmware before I even design it.
No rush at all. Enjoy the holiday! |
|
Hi @LinuxJedi, you landed the One thing I want to check first, and I suspect I'm the one missing something. The sampler is an input device (the Amiga reads the data lines to digitize audio), so it hooks the parallel data read. I put that on CIA-A PRB ( But Assuming that's sorted, my plan is to add an input path to Does that sound right, and is there a CIA detail I should know before I move the sampler onto the shared port? |
You are 100% right and I've made a mistake in my implementation. I'm more than happy for you to fix it up, alternatively I can do it. |
|
Great, thanks. I'll do the whole thing in this PR then: move the parallel data/strobe to CIA-A (ack already lands there) and add the sampler as an input device on One heads-up: I've got no Amiga printing setup here, so I can't actually test the printer-capture end after the CIA move. I'll keep your |
f25668f to
52f05df
Compare
…work Adds an 8-bit audio sampler (digitizer) as a parallel-port input peripheral, the emulated equivalent of a classic sampler cartridge (AudioMaster, ProTracker, OctaMED, TurboSound). It captures from a host audio input via cpal and presents each read of the data lines as an 8-bit offset-binary sample in emulated time, mono (host L+R summed). The capture model is an independent re-implementation of the method in WinUAE's sampler.cpp (Toni Wilen); the hardware model follows the open-amiga-sampler schematics. Built on top of the existing ParallelPort framework rather than a second abstraction: - Correct the parallel-port CIA wiring. The Centronics data pins and PC strobe are CIA-A port B ($BFE101), not CIA-B: service_parallel_strobe now takes the data and PC pulse from CIA-A (the /ACK to CIA-A FLAG was already correct), and fires on CIA-A port-B access. (I can't test the printer capture end here; the sampler input side is verified.) - Extend ParallelPort with an input path: read_data(at_cck) -> Option<u8>, default None, called on CIA-A port-B reads so an input peripheral can drive the data lines. The printer keeps the default. - CpalSampler implements ParallelPort. Its cpal stream is !Send, so open() returns the stream (kept on the main thread in the window) paired with the Send read-port that lives in the bus; the cpal code is gated behind the frontend feature like the audio output. - Unify [parallel] into a device model: device = "none" | "printer" | "sampler" (a bare output path still implies the printer). New per-run flags --parallel, --sampler-audio-input, --sampler-input-gain, and --sampler-list-audio-inputs mirror the serial/audio flags. - Launcher gains a Parallel tab. The Serial and Parallel tabs hide their device-specific rows when inactive (MIDI endpoints off MIDI mode; sampler input/gain off the sampler) rather than greying them. The runtime menu gains Sampler In / Sampler Gain when a sampler is attached, and Cmd/Alt+Shift +/- steps the gain live. Sampler input gain is expressed in decibels (0 dB unity, -24..+24 dB), converted to a linear multiplier at apply time. Docs, example config, and tests updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
52f05df to
6e9fb15
Compare
|
Pushed a big update, and rebased onto current main (the per-port-controllers work) so it's conflict-free — the new Parallel tab sits next to the Input tab. It now lives inside the ParallelPort framework instead of the separate abstraction I'd started with. On the CIA question from earlier: made the fix we landed on. The parallel data and PC strobe come off CIA-A now (data at $BFE101, /ACK already went to CIA-A FLAG), and the strobe service fires on CIA-A port-B access. The sampler reads the data lines through a new read_data() on the trait (default None, so the printer is untouched). One thing I still can't test my end is the printer capture after the CIA move — if you can sanity-check a real capture still lands, that'd be great. The sampler side I've tested with ProTracker, OctaMED and TurboSound through a loopback device / MacBook microphone. All the Copilot comments are addressed, but this is quite a meaty change, so probably worth another review. Providing all is good with the actual parallel port sampler, I'm happy to add the printer stuff to the GUI as well. I'm also tempted to merge the separate Serial/Parallel tabs into something like "I/O Ports" then have both serial and parallel ports under there in the GUI front end. I also think the new "Host Mounts" tab should be a second page of options under "Hard Disk," but this is all purely cosmetic. |
Thank you for this amazing work.
So, printer doesn't yet work. But that is because some status lines aren't hooked up. I will do a small follow-up PR that hooks them up properly and everything should work correctly.
Yes please. I'm going to cut 0.12 beta today as we have a lot of meaty stuff already and some bug fixes that I really want released. But I would love that as a follow-up. |
LinuxJedi
left a comment
There was a problem hiding this comment.
Fantastic work, and thanks for fixing my CIA hookup gaffs.
Add a small device framework for the Amiga parallel port and its first device, an 8-bit audio sampler.
The framework is the point: the port is modelled so future devices (a printer, a game's protection dongle.etc) slot in without touching the core, and the sampler is a working example.
Framework
src/parallel.rsdefinesParallelPortDevice(object-safe, host-backend-free so the core stays testable). The bus holds an optional attached device and offers it the data lines on a CIA-A PRB access ($BFE101): a CPU read callsread_data-- a device that drives the lines returns the byte, others returnNoneand the port register value stands -- and a CPU write callswrite_data, which an output device consumes and others ignore. It is host-IO, not machine state: serde-skipped on the bus, re-attached after a state load, so no STATE_VERSION change and existing saves keep loading.Both the config and the CLI mirror
[serial]:[parallel] device = "none" | "sampler"(or--parallel DEVICE) selects the device, then device-prefixed options configure it. A future printer is--parallel printer+--printer-*, no clashes.Audio sampler
--parallel sampler(or[parallel] device = "sampler") attaches the digitizer the classic Amiga samplers (AMAS, DSS, Megalosound, the open-amiga-sampler) are -- an ADC on the data lines that sampling software reads in a tight loop to record. It captures from a host audio input through cpal (same CoreAudio/WASAPI/ALSA path as the audio output;--sampler-audio-input NAME,--sampler-list-audio-inputs), and each PRB read returns the sample for the elapsed emulated time, so the input lines up however fast/slow the Amiga polls. The value is 8-bit offset-binary (128 = silence). These are mono units, so host left+right are summed into the single input -- which also means a stereo source is captured whole.--sampler-input-gainis a linear preamp (max 16x/~+24 dB). Naming a sampler option selects the sampler, as a MIDI endpoint selects--serial midi.GUI
A Parallel tab (below Serial): a Device picker (None / Sampler); the sampler's Audio input and Input gain (in dB) rows appear only when Sampler is selected, so a future device's options never clutter the tab. The runtime menu gains Sampler In and Sampler Gain items (only while a sampler is attached) that switch the input device and gain live -- the capture stream is rebuilt on the main thread (the cpal
Streamis!Sendon macOS).Cmd+Shift +/-(Alt+Shift +/-) nudges the gain 3 dB when attached. While here, the same dynamic-rows treatment is applied to the Serial tab: the MIDI in/out pickers now appear only in MIDI mode instead of being greyed out otherwise.Host input handling
Mirrors the audio output picker: on Linux the GUI drops ALSA's redundant "default" when it is the system default input (kept in the CLI list), and the picker re-enumerates on demand so a newly connected input shows up in the GUI/menu without a restart. Individual PipeWire/PulseAudio sinks aren't ALSA devices, so only the default route is offered there; macOS/Windows select each directly. (macOS note: a bare CLI binary can't prompt for the microphone permission -- grant the terminal mic access, or use a virtual loopback; a bundled .app would carry the usage description.)
Credit and licensing
The sampler's emulation approach -- a real-time capture ring read by elapsed emulated time, 8-bit offset-binary, the latency trim -- is an independent Rust implementation following the method in WinUAE's
sampler.cpp(Toni Wilen); no code was copied. WinUAE is GPL-2.0-or-later, compatible with this GPL-3.0-or-later project. The hardware model (an ADC0820-style 8-bit ADC on the data lines, mono) follows the open-amiga-sampler project's schematics (github.com/echolevel/open-amiga-sampler). Both are credited in the source.Verification
-D warnings+ fmt clean, default and--no-default-features; 1323 tests, 0 failures. STATE_VERSION unchanged.[parallel]config parse/override (incl. the sampler-selects-implicitly rule), and the launcher's dynamic Serial/Parallel rows are unit-tested.[parallel],copperline.example.toml, and--help.Screens