Skip to content
This repository was archived by the owner on Nov 18, 2025. It is now read-only.

Commit bb23014

Browse files
committed
cleanup: reduce LUNA source changes to minimum, add README
0 parents  commit bb23014

File tree

14 files changed

+1392
-0
lines changed

14 files changed

+1392
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
build/
2+
*__pycache__*

.gitmodules

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[submodule "deps/eurorack-pmod"]
2+
path = deps/eurorack-pmod
3+
url = https://github.com/apfelaudio/eurorack-pmod
4+
[submodule "deps/luna"]
5+
path = deps/luna
6+
url = https://github.com/schnommus/luna

Dockerfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
FROM ubuntu:22.04
2+
3+
# Install build dependencies
4+
ENV DEBIAN_FRONTEND=noninteractive
5+
RUN apt-get update && apt-get install -y \
6+
python3 \
7+
python3-pip \
8+
python3-venv \
9+
curl \
10+
jq \
11+
git
12+
13+
# Install oss-cad-suite
14+
RUN curl -L $(curl -s "https://api.github.com/repos/YosysHQ/oss-cad-suite-build/releases/latest" \
15+
| jq --raw-output '.assets[].browser_download_url' | grep "linux-x64") --output oss-cad-suite-linux-x64.tgz \
16+
&& tar zxvf oss-cad-suite-linux-x64.tgz
17+
ENV PATH="${PATH}:/oss-cad-suite/bin/"
18+
19+
# Update pip
20+
RUN pip3 install --upgrade pip
21+
22+
# to compile OK --
23+
# pip install .
24+
# git submodule update --init --recursive

LICENSE

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
BSD 3-Clause License
2+
3+
Copyright (c) Katherine J. Temkin <[email protected]>
4+
Copyright (c) 2019-2020, Great Scott Gadgets <[email protected]>
5+
Copyright (c) 2021, Hans Baier <[email protected]>
6+
Copyright (c) 2024, Sebastian Holzapfel <[email protected]>
7+
8+
Redistribution and use in source and binary forms, with or without
9+
modification, are permitted provided that the following conditions are met:
10+
11+
* Redistributions of source code must retain the above copyright notice, this
12+
list of conditions and the following disclaimer.
13+
14+
* Redistributions in binary form must reproduce the above copyright notice,
15+
this list of conditions and the following disclaimer in the documentation
16+
and/or other materials provided with the distribution.
17+
18+
* Neither the name of the copyright holder nor the names of its
19+
contributors may be used to endorse or promote products derived from
20+
this software without specific prior written permission.
21+
22+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
23+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
24+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
26+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
27+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
28+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
30+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
31+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Eurorack PMOD - USB Soundcard
2+
3+
This project allows a [`eurorack-pmod`](https://github.com/apfelaudio/eurorack-pmod) to be used as an 8-channel (4in + 4out) USB2 sound card. Currently it has the following limitations:
4+
5+
- Only 48KHz / 16bit sample rate supported
6+
- LambdaConcept ECPIX-5 is the only (tested) target platform
7+
8+
## Connecting
9+
10+
- The `eurorack-pmod` should be connected to PMOD0.
11+
- CN16 is the USB-C device port upon which the device will enumerate
12+
- Power jumper set to `USB-2` so it's possible to disconnect & reconnect the LUNA USB port while the board is powered from the debug interface port.
13+
14+
## Prebuilt bitstreams
15+
16+
If you don't want to build yourself, I have attached some prebuilt and tested bitstreams to the GitHub releases.
17+
18+
## Building
19+
20+
Clone the repository and fetch all submodules:
21+
22+
```bash
23+
git clone <this repository>
24+
cd <this repository>
25+
git submodule update --init --recursive
26+
```
27+
28+
This project is based on a fork of LUNA. There are a few different ways you can keep your python environment isolated - python venv or by using a container. Here I will be using a container as it should be more consistent across other OSs/systems.
29+
30+
First, let's build the container (assuming you have docker installed)
31+
32+
```bash
33+
build -f Dockerfile -t luna .
34+
```
35+
36+
Now we can run the container with this repository mounted inside it. This allows us to run commands inside the container and the filesystem will be shared between host and container (you can build and then see the built bitstreams from the host).
37+
38+
```bash
39+
# run this from inside the git repository
40+
docker run -it --mount src="$(pwd)",target=/eurorack-pmod-usb-soundcard,type=bind luna
41+
```
42+
43+
From the container, install the fork of LUNA and install all its dependencies:
44+
```bash
45+
cd eurorack-pmod-usb-soundcard
46+
pip3 install deps/luna
47+
```
48+
49+
Now you can build a bitstream, it will end up in `build/top.bit`.
50+
```bash
51+
# From the root directory of this repository
52+
LUNA_PLATFORM="ecpix5:ECPIX5_85F_Platform" python3 rtl/top.py --dry-run --keep-files
53+
```
54+
55+
From outside the container, if you have `openFPGALoader` installed you can flash this to ECPIX like so (with R03 boards)
56+
```bash
57+
openFPGALoader -b ecpix5_r03 <this_repo>/build/top.bit
58+
```
59+
60+
If you plug in the USB-C, you should see something like:
61+
62+
```bash
63+
# Check it enumerates
64+
$ dmesg
65+
...
66+
[ 5489.544374] usb 5-1: new high-speed USB device number 9 using xhci_hcd
67+
[ 5489.687203] usb 5-1: New USB device found, idVendor=1209, idProduct=1234, bcdDevice= 0.01
68+
[ 5489.687208] usb 5-1: New USB device strings: Mfr=1, Product=2, SerialNumber=3
69+
[ 5489.687210] usb 5-1: Product: PMODface
70+
[ 5489.687211] usb 5-1: Manufacturer: ApfelAudio
71+
[ 5489.687212] usb 5-1: SerialNumber: 1234
72+
73+
# Check it's picked up as a sound card
74+
$ aplay -l
75+
...
76+
card 1: PMODface [PMODface], device 0: USB Audio [USB Audio]
77+
Subdevices: 1/1
78+
Subdevice #0: subdevice #0
79+
80+
# To test 4ch outputs on Linux you can use something like
81+
$ speaker-test --device plughw:CARD=PMODface -c 4
82+
```
83+
84+
## Porting
85+
86+
It should not be too hard to port this to other FPGA boards, as long as they:
87+
88+
- Are supported by [amaranth-boards](https://github.com/amaranth-lang/amaranth-boards/).
89+
- [Were supported by LUNA](https://github.com/greatscottgadgets/luna-boards) (i.e. USB functionality was mapped and tested at some point)
90+
- You will need to create your own platform file like `rtl/ecpix5.py`, add another PLL output for the audio domain and set `LUNA_PLATFORM` appropriately.
91+
92+
## Previous Work
93+
94+
This work builds on the following fantastic open-source projects:
95+
96+
- [LUNA](https://github.com/greatscottgadgets/luna) (Great Scott Gadgets) - open-source USB framework for FPGAs.
97+
- [deca-usb2-audio-interface](https://github.com/amaranth-farm/deca-usb2-audio-interface) (hansfbaier@) - implementation of isochronous endpoints and most of the audio descriptor handling is lifted from here.

deps/eurorack-pmod

Submodule eurorack-pmod added at e7867c8

deps/luna

Submodule luna added at bec6179

rtl/audio_to_channels.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Copyright (c) 2024 Seb Holzapfel <[email protected]>
2+
#
3+
# SPDX-License-Identifier: BSD--3-Clause
4+
5+
from amaranth import *
6+
from amaranth.lib.fifo import AsyncFIFO
7+
8+
class AudioToChannels(Elaboratable):
9+
10+
"""
11+
Domain crossing logic to move samples from `eurorack-pmod` logic in the audio domain
12+
to `channels_to_usb_stream` and `usb_stream_to_channels` logic in the USB domain.
13+
"""
14+
15+
def __init__(self, eurorack_pmod, to_usb_stream, from_usb_stream):
16+
17+
self.to_usb = to_usb_stream
18+
self.from_usb = from_usb_stream
19+
self.eurorack_pmod = eurorack_pmod
20+
21+
def elaborate(self, platform) -> Module:
22+
23+
m = Module()
24+
25+
eurorack_pmod = self.eurorack_pmod
26+
27+
# Sample widths
28+
SW = eurorack_pmod.width # Sample width used in underlying I2S driver.
29+
SW_USB = self.to_usb.payload.width # Sample width used for USB transfers.
30+
N_ZFILL = SW_USB - SW # Zero padding if SW < SW_USB
31+
32+
assert(N_ZFILL >= 0)
33+
34+
#
35+
# INPUT SIDE
36+
# eurorack-pmod calibrated INPUT samples -> USB Channel stream -> HOST
37+
#
38+
39+
m.submodules.adc_fifo = adc_fifo = AsyncFIFO(width=SW*4, depth=64, w_domain="audio", r_domain="usb")
40+
41+
# (audio domain) on every sample strobe, latch and write all channels concatenated into one entry
42+
# of adc_fifo.
43+
44+
m.d.audio += [
45+
# FIXME: ignoring rdy in write domain. Should be fine as write domain
46+
# will always be slower than the read domain, but should be fixed.
47+
adc_fifo.w_en.eq(eurorack_pmod.fs_strobe),
48+
adc_fifo.w_data[ :SW*1].eq(eurorack_pmod.cal_in0),
49+
adc_fifo.w_data[SW*1:SW*2].eq(eurorack_pmod.cal_in1),
50+
adc_fifo.w_data[SW*2:SW*3].eq(eurorack_pmod.cal_in2),
51+
adc_fifo.w_data[SW*3:SW*4].eq(eurorack_pmod.cal_in3),
52+
]
53+
54+
# (usb domain) unpack samples from the adc_fifo (one big concatenated
55+
# entry with samples for all channels once per sample strobe) and feed them
56+
# into ChannelsToUSBStream with one entry per channel, i.e 1 -> 4 entries
57+
# per sample strobe in the audio domain.
58+
59+
# Storage for samples in the USB domain as we send them to the channel stream.
60+
adc_latched = Signal(SW*4)
61+
62+
with m.FSM(domain="usb") as fsm:
63+
64+
with m.State('WAIT'):
65+
m.d.usb += self.to_usb.valid.eq(0),
66+
with m.If(adc_fifo.r_rdy):
67+
m.d.usb += adc_fifo.r_en.eq(1)
68+
m.next = 'LATCH'
69+
70+
with m.State('LATCH'):
71+
m.d.usb += [
72+
adc_fifo.r_en.eq(0),
73+
adc_latched.eq(adc_fifo.r_data)
74+
]
75+
m.next = 'CH0'
76+
77+
def generate_channel_states(channel, next_state_name):
78+
with m.State(f'CH{channel}'):
79+
m.d.usb += [
80+
# FIXME: currently filling bottom bits with zeroes for SW bit -> SW_USB bit
81+
# sample conversion. Better to just switch native rate of I2S driver.
82+
self.to_usb.payload.eq(
83+
Cat(Const(0, N_ZFILL), adc_latched[channel*SW:(channel+1)*SW])),
84+
self.to_usb.channel_no.eq(channel),
85+
self.to_usb.valid.eq(1),
86+
]
87+
m.next = f'CH{channel}-SEND'
88+
with m.State(f'CH{channel}-SEND'):
89+
with m.If(self.to_usb.ready):
90+
m.d.usb += self.to_usb.valid.eq(0)
91+
m.next = next_state_name
92+
93+
generate_channel_states(0, 'CH1')
94+
generate_channel_states(1, 'CH2')
95+
generate_channel_states(2, 'CH3')
96+
generate_channel_states(3, 'WAIT')
97+
98+
#
99+
# OUTPUT SIDE
100+
# HOST -> USB Channel stream -> eurorack-pmod calibrated OUTPUT samples.
101+
#
102+
103+
for n, output in zip(range(4), [eurorack_pmod.cal_out0, eurorack_pmod.cal_out1,
104+
eurorack_pmod.cal_out2, eurorack_pmod.cal_out3]):
105+
106+
# FIXME: we shouldn't need one FIFO per channel
107+
fifo = AsyncFIFO(width=SW, depth=64, w_domain="usb", r_domain="audio")
108+
setattr(m.submodules, f'dac_fifo{n}', fifo)
109+
110+
# (usb domain) if the channel_no matches, demux it into the correct channel FIFO
111+
m.d.comb += [
112+
fifo.w_data.eq(self.from_usb.payload[N_ZFILL:]),
113+
fifo.w_en.eq((self.from_usb.channel_no == n) &
114+
self.from_usb.valid),
115+
]
116+
117+
# (audio domain) once fs_strobe hits, write the next pending sample to eurorack_pmod.
118+
with m.FSM(domain="audio") as fsm:
119+
with m.State('READ'):
120+
with m.If(eurorack_pmod.fs_strobe & fifo.r_rdy):
121+
m.d.audio += fifo.r_en.eq(1)
122+
m.next = 'SEND'
123+
with m.State('SEND'):
124+
m.d.audio += [
125+
fifo.r_en.eq(0),
126+
output.eq(fifo.r_data),
127+
]
128+
m.next = 'READ'
129+
130+
# FIXME: make this less lenient
131+
m.d.comb += self.from_usb.ready.eq(
132+
m.submodules.dac_fifo0.w_rdy | m.submodules.dac_fifo1.w_rdy |
133+
m.submodules.dac_fifo2.w_rdy | m.submodules.dac_fifo3.w_rdy)
134+
135+
return m

0 commit comments

Comments
 (0)