Skip to content

parallel: audio sampler, on a general parallel-port framework#138

Merged
LinuxJedi merged 1 commit into
LinuxJedi:mainfrom
hobbo91:feature/parallel-sampler
Jul 18, 2026
Merged

parallel: audio sampler, on a general parallel-port framework#138
LinuxJedi merged 1 commit into
LinuxJedi:mainfrom
hobbo91:feature/parallel-sampler

Conversation

@hobbo91

@hobbo91 hobbo91 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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.rs defines ParallelPortDevice (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 calls read_data -- a device that drives the lines returns the byte, others return None and the port register value stands -- and a CPU write calls write_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-gain is 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 Stream is !Send on 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

  • clippy -D warnings + fmt clean, default and --no-default-features; 1323 tests, 0 failures. STATE_VERSION unchanged.
  • Recording verified on macOS with ProTracker, OctaMED, AudioMaster and TurboSound, capturing both from an external USB audio interface and via inter-app routing (BlackHole on macOS; a virtual audio cable on Windows). The bus PRB read/write routing, mono ADC conversion, [parallel] config parse/override (incl. the sampler-selects-implicitly rule), and the launcher's dynamic Serial/Parallel rows are unit-tested.
  • Docs: README "Parallel port", configuration guide [parallel], copperline.example.toml, and --help.

Screens

Screenshot 2026-07-06 at 16 40 59 Screenshot 2026-07-06 at 13 55 09 Screenshot 2026-07-06 at 13 54 54 Screenshot 2026-07-06 at 13 57 59

@hobbo91
hobbo91 force-pushed the feature/parallel-sampler branch 2 times, most recently from 6dc6eab to f25668f Compare July 6, 2026 16:55
@LinuxJedi
LinuxJedi requested a review from Copilot July 7, 2026 05:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/sampler.rs
Comment on lines +77 to +80
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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot, will clean that up

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the ADC conversion is now * 128.0 + 128, so -full maps to 0, silence to 128, +full clamps to 255.

Comment thread src/sampler.rs Outdated
Comment on lines +371 to +374
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated — the test now asserts sample_to_byte(-1.0) == 0 and (1.0) == 255 to match the corrected conversion.

Comment thread src/video/window.rs Outdated
} else {
(String::new(), String::new())
};
let sampler_active = self.emu.bus().has_parallel_device();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/video/window.rs Outdated
Comment on lines +1122 to +1125
fn step_sampler_gain(&mut self, delta_db: f32, wrap: bool) {
if !self.emu.bus().has_parallel_device() {
return;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — step_sampler_gain is now gated on the session sampler request (self.sampler.enabled), so it no-ops for a non-sampler device.

Comment thread src/video/ui.rs Outdated
Comment on lines +107 to +108
// 7 leading + up to 2 MIDI + up to 3 sampler + 10 trailing items, sized so
// appending never reallocates.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted - was originally going to have an option for stereo/mono but realised it was a lot more complexity for such a niche feature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the comment now reads "2 sampler" to match the two items pushed.

Comment thread src/video/launcher.rs Outdated
Comment on lines +989 to +995
// 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);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread src/video/launcher.rs Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the above I ended up dropping channel mode

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the sampler rows are now a 3-row set (PARALLEL_ROWS_SAMPLER: Device/Audio input/Input gain) with no stale count comment.

Comment thread src/video/window.rs Outdated
Comment on lines +2442 to +2446
self.ui.control_at(
pos,
self.serial_is_midi,
self.emu.bus().has_parallel_device(),
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack, will clean this up

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — control_at now uses self.sampler_stream.is_some(), the same condition as the menu draw path, so hit-testing and drawing agree.

@LinuxJedi LinuxJedi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many thanks @hobbo91, cool idea.

Can you please take a look at the Copilot comments, I think mostly minor, but some probably should be addressed.

@hobbo91

hobbo91 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Many thanks @hobbo91, cool idea.

Can you please take a look at the Copilot comments, I think mostly minor, but some probably should be addressed.

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.

@LinuxJedi

Copy link
Copy Markdown
Owner

Many thanks @hobbo91, cool idea.
Can you please take a look at the Copilot comments, I think mostly minor, but some probably should be addressed.

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).

Happy to support this kind of thing.

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 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.

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.

No rush at all. Enjoy the holiday!

@hobbo91

hobbo91 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Hi @LinuxJedi, you landed the ParallelPort framework while I was out, which is great news for this one. The sampler in #138 should live inside that instead of adding a second parallel abstraction, so I want to rework it that way.

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 ($BFE101) and it works with real software (ProTracker, OctaMED, TurboSound.etc). WinUAE for example reads and writes parallel data in ReadCIAA/WriteCIAA reg 1, the HRM has it at CIA-A PRB, and cia.rs's own header says CIA-A is $BFE001/$BFE101.

But service_parallel_strobe() takes the data from cia_b.port_b_pins() and strobes off CIA-B's PC, while sending /ACK back to CIA-A FLAG. So the ack is on CIA-A but the data is on CIA-B, and the sampler only sees anything on CIA-A. Is the CIA-B side deliberate (some strobe detail I've got wrong), or should the data sit on CIA-A with the ack?

Assuming that's sorted, my plan is to add an input path to ParallelPort (a read_data() -> Option<u8> next to strobe(), default None), have the cpal sampler implement it, and fold [parallel] into one device = "printer" | "sampler" model instead of two overlapping sections.

Does that sound right, and is there a CIA detail I should know before I move the sampler onto the shared port?

@LinuxJedi

Copy link
Copy Markdown
Owner

Hi @LinuxJedi, you landed the ParallelPort framework while I was out, which is great news for this one. The sampler in #138 should live inside that instead of adding a second parallel abstraction, so I want to rework it that way.

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 ($BFE101) and it works with real software (ProTracker, OctaMED, TurboSound.etc). WinUAE for example reads and writes parallel data in ReadCIAA/WriteCIAA reg 1, the HRM has it at CIA-A PRB, and cia.rs's own header says CIA-A is $BFE001/$BFE101.

But service_parallel_strobe() takes the data from cia_b.port_b_pins() and strobes off CIA-B's PC, while sending /ACK back to CIA-A FLAG. So the ack is on CIA-A but the data is on CIA-B, and the sampler only sees anything on CIA-A. Is the CIA-B side deliberate (some strobe detail I've got wrong), or should the data sit on CIA-A with the ack?

Assuming that's sorted, my plan is to add an input path to ParallelPort (a read_data() -> Option<u8> next to strobe(), default None), have the cpal sampler implement it, and fold [parallel] into one device = "printer" | "sampler" model instead of two overlapping sections.

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.

@hobbo91

hobbo91 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

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 ParallelPort, folding the config into one [parallel] device model.

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 ParallelPort/FileParallelPort code and tests intact and make the wiring change carefully, but could you sanity-check that a real printer capture still works before it goes in? The sampler (input) side I can test properly.

@hobbo91
hobbo91 force-pushed the feature/parallel-sampler branch from f25668f to 52f05df Compare July 17, 2026 14:40
…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>
@hobbo91
hobbo91 force-pushed the feature/parallel-sampler branch from 52f05df to 6e9fb15 Compare July 17, 2026 22:45
@hobbo91

hobbo91 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

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.

@LinuxJedi

Copy link
Copy Markdown
Owner

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.

Thank you for this amazing work.

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.

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.

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.

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 LinuxJedi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic work, and thanks for fixing my CIA hookup gaffs.

@LinuxJedi
LinuxJedi merged commit 3ab391d into LinuxJedi:main Jul 18, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants