├── .gitignore ├── .gitmodules ├── Dockerfile ├── rtl ├── util.py ├── usb_stream_to_channels.py ├── channels_to_usb_stream.py ├── audio_to_channels.py ├── ecpix5.py ├── eurorack_pmod.py └── top.py ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | *__pycache__* 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 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 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 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 25 | -------------------------------------------------------------------------------- /rtl/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 Hans Baier 2 | # Copyright (c) 2024 Seb Holzapfel 3 | # 4 | # SPDX-License-Identifier: BSD--3-Clause 5 | 6 | from amaranth import * 7 | 8 | # some things lifted from `amlib`, given we don't need anyting else from there. 9 | 10 | class EdgeToPulse(Elaboratable): 11 | """ 12 | each rising edge of the signal edge_in will be 13 | converted to a single clock pulse on pulse_out 14 | """ 15 | def __init__(self): 16 | self.edge_in = Signal() 17 | self.pulse_out = Signal() 18 | 19 | def elaborate(self, platform) -> Module: 20 | m = Module() 21 | 22 | edge_last = Signal() 23 | 24 | m.d.sync += edge_last.eq(self.edge_in) 25 | with m.If(self.edge_in & ~edge_last): 26 | m.d.comb += self.pulse_out.eq(1) 27 | with m.Else(): 28 | m.d.comb += self.pulse_out.eq(0) 29 | 30 | return m 31 | 32 | def connect_fifo_to_stream(fifo, stream, firstBit: int=None, lastBit: int=None) -> None: 33 | """Connects the output of the FIFO to the of the stream. Data flows from the fifo the stream. 34 | It is assumed the payload occupies the lowest significant bits 35 | This function connects first/last signals if their bit numbers are given 36 | """ 37 | 38 | result = [ 39 | stream.valid.eq(fifo.r_rdy), 40 | fifo.r_en.eq(stream.ready), 41 | stream.payload.eq(fifo.r_data), 42 | ] 43 | 44 | if firstBit: 45 | result.append(stream.first.eq(fifo.r_data[firstBit])) 46 | if lastBit: 47 | result.append(stream.last.eq(fifo.r_data[lastBit])) 48 | 49 | return result 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) Katherine J. Temkin 4 | Copyright (c) 2019-2020, Great Scott Gadgets 5 | Copyright (c) 2021, Hans Baier 6 | Copyright (c) 2024, Sebastian Holzapfel 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. 32 | -------------------------------------------------------------------------------- /rtl/usb_stream_to_channels.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 Hans Baier 2 | # 3 | # SPDX-License-Identifier: BSD--3-Clause 4 | 5 | from amaranth import * 6 | from luna.gateware.stream import StreamInterface 7 | 8 | class USBStreamToChannels(Elaboratable): 9 | def __init__(self, max_nr_channels=2): 10 | # parameters 11 | self._max_nr_channels = max_nr_channels 12 | self._channel_bits = Shape.cast(range(max_nr_channels)).width 13 | 14 | # ports 15 | self.usb_stream_in = StreamInterface() 16 | self.channel_stream_out = StreamInterface(payload_width=24, extra_fields=[("channel_no", self._channel_bits)]) 17 | 18 | def elaborate(self, platform): 19 | m = Module() 20 | 21 | out_channel_no = Signal(self._channel_bits) 22 | out_sample = Signal(16) 23 | usb_valid = Signal() 24 | usb_first = Signal() 25 | usb_payload = Signal(8) 26 | out_ready = Signal() 27 | 28 | m.d.comb += [ 29 | usb_first.eq(self.usb_stream_in.first), 30 | usb_valid.eq(self.usb_stream_in.valid), 31 | usb_payload.eq(self.usb_stream_in.payload), 32 | out_ready.eq(self.channel_stream_out.ready), 33 | self.usb_stream_in.ready.eq(out_ready), 34 | ] 35 | 36 | m.d.sync += [ 37 | self.channel_stream_out.valid.eq(0), 38 | self.channel_stream_out.first.eq(0), 39 | self.channel_stream_out.last.eq(0), 40 | ] 41 | 42 | with m.If(usb_valid & out_ready): 43 | with m.FSM(): 44 | with m.State("B0"): 45 | with m.If(usb_first): 46 | m.d.sync += out_channel_no.eq(0) 47 | with m.Else(): 48 | m.d.sync += out_channel_no.eq(out_channel_no + 1) 49 | 50 | m.next = "B1" 51 | 52 | with m.State("B1"): 53 | m.d.sync += out_sample[:8].eq(usb_payload) 54 | m.next = "B2" 55 | 56 | with m.State("B2"): 57 | m.d.sync += out_sample[8:16].eq(usb_payload) 58 | m.next = "B3" 59 | 60 | with m.State("B3"): 61 | m.d.sync += [ 62 | self.channel_stream_out.payload.eq(Cat(out_sample, usb_payload)), 63 | self.channel_stream_out.valid.eq(1), 64 | self.channel_stream_out.channel_no.eq(out_channel_no), 65 | self.channel_stream_out.first.eq(out_channel_no == 0), 66 | self.channel_stream_out.last.eq(out_channel_no == (2**self._channel_bits - 1)), 67 | ] 68 | m.next = "B0" 69 | 70 | return m 71 | 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | UPDATE: In general I have moved away from LiteX in favor of a more modern Amaranth workflow. This repository is not maintained. 2 | 3 | To achieve something similar, take a look at [the Tiliqua project](https://github.com/apfaudio/tiliqua/tree/main/gateware/src/tiliqua/usb_audio) 4 | 5 | # Eurorack PMOD - USB Soundcard 6 | 7 | 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: 8 | 9 | - Only 48KHz / 16bit sample rate supported 10 | - LambdaConcept ECPIX-5 is the only (tested) target platform 11 | - For now, enumeration and audio only tested on Linux 12 | 13 | ## Connecting 14 | 15 | - The `eurorack-pmod` should be connected to PMOD0. 16 | - **WARN**: if you are using an older (R3.1) hw revision, make sure to build with `PMOD_HW=HW_R31` in the build invocation below. Otherwise the gateware will freeze as it will try to talk to the touch IC (which is not there for R3.1). 17 | - CN16 is the USB-C device port upon which the device will enumerate 18 | - 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. 19 | 20 | ## Prebuilt bitstreams 21 | 22 | If you don't want to build yourself, I have attached some prebuilt and tested bitstreams to the GitHub releases. 23 | 24 | ## Building 25 | 26 | Clone the repository and fetch all submodules: 27 | 28 | ```bash 29 | git clone 30 | cd 31 | git submodule update --init --recursive 32 | ``` 33 | 34 | 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. 35 | 36 | First, let's build the container (assuming you have docker installed) 37 | 38 | ```bash 39 | build -f Dockerfile -t luna . 40 | ``` 41 | 42 | 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). 43 | 44 | ```bash 45 | # run this from inside the git repository 46 | docker run -it --mount src="$(pwd)",target=/eurorack-pmod-usb-soundcard,type=bind luna 47 | ``` 48 | 49 | From the container, install the fork of LUNA and install all its dependencies: 50 | ```bash 51 | cd eurorack-pmod-usb-soundcard 52 | pip3 install deps/luna 53 | ``` 54 | 55 | Now you can build a bitstream, it will end up in `build/top.bit`. 56 | ```bash 57 | # From the root directory of this repository 58 | PMOD_HW=HW_R33 LUNA_PLATFORM="ecpix5:ECPIX5_85F_Platform" python3 rtl/top.py --dry-run --keep-files 59 | ``` 60 | 61 | From outside the container, if you have `openFPGALoader` installed you can flash this to ECPIX like so (with R03 boards) 62 | ```bash 63 | openFPGALoader -b ecpix5_r03 /build/top.bit 64 | ``` 65 | 66 | If you plug in the USB-C, you should see something like: 67 | 68 | ```bash 69 | # Check it enumerates 70 | $ dmesg 71 | ... 72 | [ 5489.544374] usb 5-1: new high-speed USB device number 9 using xhci_hcd 73 | [ 5489.687203] usb 5-1: New USB device found, idVendor=1209, idProduct=1234, bcdDevice= 0.01 74 | [ 5489.687208] usb 5-1: New USB device strings: Mfr=1, Product=2, SerialNumber=3 75 | [ 5489.687210] usb 5-1: Product: PMODface 76 | [ 5489.687211] usb 5-1: Manufacturer: ApfelAudio 77 | [ 5489.687212] usb 5-1: SerialNumber: 1234 78 | 79 | # Check it's picked up as a sound card 80 | $ aplay -l 81 | ... 82 | card 1: PMODface [PMODface], device 0: USB Audio [USB Audio] 83 | Subdevices: 1/1 84 | Subdevice #0: subdevice #0 85 | 86 | # To test 4ch outputs on Linux you can use something like 87 | $ speaker-test --device plughw:CARD=PMODface -c 4 88 | ``` 89 | 90 | ## Porting 91 | 92 | It should not be too hard to port this to other FPGA boards, as long as they: 93 | 94 | - Are supported by [amaranth-boards](https://github.com/amaranth-lang/amaranth-boards/). 95 | - [Were supported by LUNA](https://github.com/greatscottgadgets/luna-boards) (i.e. USB functionality was mapped and tested at some point) 96 | - 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. 97 | 98 | ## Previous Work 99 | 100 | This work builds on the following fantastic open-source projects: 101 | 102 | - [LUNA](https://github.com/greatscottgadgets/luna) (Great Scott Gadgets) - open-source USB framework for FPGAs. 103 | - [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. 104 | -------------------------------------------------------------------------------- /rtl/channels_to_usb_stream.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 Hans Baier 2 | # 3 | # SPDX-License-Identifier: BSD--3-Clause 4 | 5 | from amaranth import * 6 | from amaranth.lib.fifo import SyncFIFO 7 | from luna.gateware.stream import StreamInterface 8 | 9 | from util import connect_fifo_to_stream 10 | 11 | class ChannelsToUSBStream(Elaboratable): 12 | def __init__(self, max_nr_channels=2, sample_width=24, max_packet_size=512): 13 | assert sample_width in [16, 24, 32] 14 | 15 | # parameters 16 | self._max_nr_channels = max_nr_channels 17 | self._channel_bits = Shape.cast(range(max_nr_channels)).width 18 | self._sample_width = sample_width 19 | self._max_packet_size = max_packet_size 20 | 21 | # ports 22 | self.usb_stream_out = StreamInterface() 23 | self.channel_stream_in = StreamInterface(payload_width=self._sample_width, extra_fields=[("channel_no", self._channel_bits)]) 24 | 25 | def elaborate(self, platform): 26 | m = Module() 27 | m.submodules.out_fifo = out_fifo = SyncFIFO(width=8, depth=self._max_packet_size) 28 | 29 | channel_stream = self.channel_stream_in 30 | channel_payload = Signal(self._sample_width) 31 | channel_valid = Signal() 32 | channel_ready = Signal() 33 | 34 | m.d.comb += [ 35 | *connect_fifo_to_stream(out_fifo, self.usb_stream_out), 36 | channel_payload.eq(channel_stream.payload), 37 | channel_valid.eq(channel_stream.valid), 38 | channel_stream.ready.eq(channel_ready), 39 | ] 40 | 41 | current_sample = Signal(32 if self._sample_width > 16 else 16) 42 | current_channel = Signal(self._channel_bits) 43 | current_byte = Signal(2 if self._sample_width > 16 else 1) 44 | 45 | last_channel = self._max_nr_channels - 1 46 | num_bytes = 4 47 | last_byte = num_bytes - 1 48 | 49 | shift = 8 if self._sample_width == 24 else 0 50 | 51 | with m.If(out_fifo.w_rdy): 52 | with m.FSM() as fsm: 53 | current_channel_next = (current_channel + 1)[:self._channel_bits] 54 | 55 | with m.State("WAIT-FIRST"): 56 | # we have to accept data until we find a first channel sample 57 | m.d.comb += channel_ready.eq(1) 58 | with m.If(channel_valid & (channel_stream.channel_no == 0)): 59 | m.d.sync += [ 60 | current_sample.eq(channel_payload << shift), 61 | current_channel.eq(0), 62 | ] 63 | m.next = "SEND" 64 | 65 | with m.State("SEND"): 66 | m.d.comb += [ 67 | out_fifo.w_data.eq(current_sample[0:8]), 68 | out_fifo.w_en.eq(1), 69 | ] 70 | m.d.sync += [ 71 | current_byte.eq(current_byte + 1), 72 | current_sample.eq(current_sample >> 8), 73 | ] 74 | 75 | with m.If(current_byte == last_byte): 76 | with m.If(channel_valid): 77 | m.d.comb += channel_ready.eq(1) 78 | 79 | m.d.sync += current_channel.eq(current_channel_next) 80 | 81 | with m.If(current_channel_next == channel_stream.channel_no): 82 | m.d.sync += current_sample.eq(channel_payload << shift) 83 | m.next = "SEND" 84 | with m.Else(): 85 | m.next = "FILL-ZEROS" 86 | 87 | with m.Else(): 88 | m.next = "WAIT" 89 | 90 | with m.State("WAIT"): 91 | with m.If(channel_valid): 92 | m.d.comb += channel_ready.eq(1) 93 | m.d.sync += [ 94 | current_sample.eq(channel_payload << shift), 95 | current_channel.eq(current_channel_next), 96 | ] 97 | m.next = "SEND" 98 | 99 | with m.State("FILL-ZEROS"): 100 | m.d.comb += [ 101 | out_fifo.w_data.eq(0), 102 | out_fifo.w_en.eq(1), 103 | ] 104 | m.d.sync += current_byte.eq(current_byte + 1) 105 | 106 | with m.If(current_byte == last_byte): 107 | m.d.sync += current_channel.eq(current_channel + 1) 108 | with m.If(current_channel == last_channel): 109 | m.next = "WAIT-FIRST" 110 | return m 111 | 112 | -------------------------------------------------------------------------------- /rtl/audio_to_channels.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 Seb Holzapfel 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 136 | -------------------------------------------------------------------------------- /rtl/ecpix5.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Great Scott Gadgets 2 | # Copyright (c) 2024 Sebastian Holzapfel 3 | # SPDX-License-Identifier: BSD-3-Clause 4 | 5 | """ ecpix5 platform definitions. normal CAR + extra 12.288MHz PLL for audio clock. """ 6 | 7 | from amaranth import * 8 | from amaranth.build import * 9 | from amaranth.vendor import LatticeECP5Platform 10 | 11 | from amaranth_boards.resources import * 12 | from amaranth_boards.ecpix5 import ECPIX545Platform as _ECPIX545Platform 13 | from amaranth_boards.ecpix5 import ECPIX585Platform as _ECPIX585Platform 14 | 15 | from luna.gateware.platform.core import LUNAPlatform 16 | 17 | 18 | __all__ = ["ECPIX5_45F_Platform", "ECPIX5_85F_Platform"] 19 | 20 | 21 | class ECPIX5DomainGenerator(Elaboratable): 22 | """ Clock generator for ECPIX5 boards. """ 23 | 24 | def __init__(self, *, clock_frequencies=None, clock_signal_name=None): 25 | pass 26 | 27 | def elaborate(self, platform): 28 | m = Module() 29 | 30 | # Create our domains. 31 | m.domains.sync = ClockDomain() 32 | m.domains.usb = ClockDomain() 33 | m.domains.fast = ClockDomain() 34 | m.domains.audio = ClockDomain() 35 | 36 | clk100 = platform.request(platform.default_clk, dir='i').i 37 | reset = platform.request(platform.default_rst, dir='i').i 38 | 39 | # Generate the clocks we need for running our SerDes. 40 | feedback = Signal() 41 | locked = Signal() 42 | m.submodules.pll = Instance("EHXPLLL", 43 | 44 | # Clock in. 45 | i_CLKI=clk100, 46 | 47 | # Generated clock outputs. 48 | o_CLKOP=feedback, 49 | o_CLKOS= ClockSignal("sync"), 50 | o_CLKOS2=ClockSignal("fast"), 51 | 52 | # Status. 53 | o_LOCK=locked, 54 | 55 | # PLL parameters... 56 | p_CLKI_DIV=1, 57 | p_PLLRST_ENA="ENABLED", 58 | p_INTFB_WAKE="DISABLED", 59 | p_STDBY_ENABLE="DISABLED", 60 | p_DPHASE_SOURCE="DISABLED", 61 | p_CLKOS3_FPHASE=0, 62 | p_CLKOS3_CPHASE=0, 63 | p_CLKOS2_FPHASE=0, 64 | p_CLKOS2_CPHASE=5, 65 | p_CLKOS_FPHASE=0, 66 | p_CLKOS_CPHASE=5, 67 | p_CLKOP_FPHASE=0, 68 | p_CLKOP_CPHASE=4, 69 | p_PLL_LOCK_MODE=0, 70 | p_CLKOS_TRIM_DELAY="0", 71 | p_CLKOS_TRIM_POL="FALLING", 72 | p_CLKOP_TRIM_DELAY="0", 73 | p_CLKOP_TRIM_POL="FALLING", 74 | p_OUTDIVIDER_MUXD="DIVD", 75 | p_CLKOS3_ENABLE="DISABLED", 76 | p_OUTDIVIDER_MUXC="DIVC", 77 | p_CLKOS2_ENABLE="ENABLED", 78 | p_OUTDIVIDER_MUXB="DIVB", 79 | p_CLKOS_ENABLE="ENABLED", 80 | p_OUTDIVIDER_MUXA="DIVA", 81 | p_CLKOP_ENABLE="ENABLED", 82 | p_CLKOS3_DIV=1, 83 | p_CLKOS2_DIV=2, 84 | p_CLKOS_DIV=4, 85 | p_CLKOP_DIV=5, 86 | p_CLKFB_DIV=1, 87 | p_FEEDBK_PATH="CLKOP", 88 | 89 | # Internal feedback. 90 | i_CLKFB=feedback, 91 | 92 | # Control signals. 93 | i_RST=reset, 94 | i_PHASESEL0=0, 95 | i_PHASESEL1=0, 96 | i_PHASEDIR=1, 97 | i_PHASESTEP=1, 98 | i_PHASELOADREG=1, 99 | i_STDBY=0, 100 | i_PLLWAKESYNC=0, 101 | 102 | # Output Enables. 103 | i_ENCLKOP=0, 104 | i_ENCLKOS=0, 105 | i_ENCLKOS2=0, 106 | i_ENCLKOS3=0, 107 | 108 | # Synthesis attributes. 109 | a_ICP_CURRENT="12", 110 | a_LPF_RESISTOR="8" 111 | ) 112 | 113 | feedback = Signal() 114 | audio_locked = Signal() 115 | m.submodules.audio_pll = Instance("EHXPLLL", 116 | 117 | # Status. 118 | o_LOCK=audio_locked, 119 | 120 | # PLL parameters... 121 | p_PLLRST_ENA="ENABLED", 122 | p_INTFB_WAKE="DISABLED", 123 | p_STDBY_ENABLE="DISABLED", 124 | p_DPHASE_SOURCE="DISABLED", 125 | p_OUTDIVIDER_MUXA="DIVA", 126 | p_OUTDIVIDER_MUXB="DIVB", 127 | p_OUTDIVIDER_MUXC="DIVC", 128 | p_OUTDIVIDER_MUXD="DIVD", 129 | 130 | p_CLKI_DIV = 4, 131 | p_CLKOP_ENABLE = "ENABLED", 132 | p_CLKOP_DIV = 29, 133 | p_CLKOP_CPHASE = 9, 134 | p_CLKOP_FPHASE = 0, 135 | p_CLKOS_ENABLE = "ENABLED", 136 | p_CLKOS_DIV = 59, 137 | p_CLKOS_CPHASE = 0, 138 | p_CLKOS_FPHASE = 0, 139 | p_FEEDBK_PATH = "CLKOP", 140 | p_CLKFB_DIV = 1, 141 | 142 | # Clock in. 143 | i_CLKI=clk100, 144 | 145 | # Internal feedback. 146 | i_CLKFB=feedback, 147 | 148 | # Control signals. 149 | i_RST=reset, 150 | i_PHASESEL0=0, 151 | i_PHASESEL1=0, 152 | i_PHASEDIR=1, 153 | i_PHASESTEP=1, 154 | i_PHASELOADREG=1, 155 | i_STDBY=0, 156 | i_PLLWAKESYNC=0, 157 | 158 | # Output Enables. 159 | i_ENCLKOP=0, 160 | i_ENCLKOS2=0, 161 | 162 | # Generated clock outputs. 163 | o_CLKOP=feedback, 164 | o_CLKOS=ClockSignal("audio"), 165 | 166 | # Synthesis attributes. 167 | a_FREQUENCY_PIN_CLKI="100", 168 | a_FREQUENCY_PIN_CLKOS="12.2881", 169 | a_ICP_CURRENT="12", 170 | a_LPF_RESISTOR="8", 171 | a_MFG_ENABLE_FILTEROPAMP="1", 172 | a_MFG_GMCREF_SEL="2" 173 | ) 174 | 175 | # Control our resets. 176 | m.d.comb += [ 177 | ResetSignal("sync") .eq(~locked), 178 | ResetSignal("fast") .eq(~locked), 179 | 180 | ResetSignal("audio") .eq(~audio_locked), 181 | ] 182 | 183 | return m 184 | 185 | 186 | class ECPIX5_45F_Platform(_ECPIX545Platform, LUNAPlatform): 187 | name = "ECPIX-5 (45F)" 188 | clock_domain_generator = ECPIX5DomainGenerator 189 | default_usb_connection = "ulpi" 190 | 191 | 192 | class ECPIX5_85F_Platform(_ECPIX585Platform, LUNAPlatform): 193 | name = "ECPIX-5 (85F)" 194 | clock_domain_generator = ECPIX5DomainGenerator 195 | default_usb_connection = "ulpi" 196 | -------------------------------------------------------------------------------- /rtl/eurorack_pmod.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 Seb Holzapfel 2 | # 3 | # SPDX-License-Identifier: BSD--3-Clause 4 | 5 | import os 6 | 7 | from amaranth import * 8 | from amaranth.build import * 9 | 10 | from util import EdgeToPulse 11 | 12 | class EurorackPmod(Elaboratable): 13 | """ 14 | Amaranth wrapper for Verilog files from `eurorack-pmod` project. 15 | 16 | Requires an "audio" clock domain running at 12.288MHz (256*Fs). 17 | 18 | There are some Amaranth I2S cores around, however they seem to 19 | use oversampling, which can be glitchy at such high bit clock 20 | rates (as needed for 4x4 TDM the AK4619 requires). 21 | """ 22 | 23 | def __init__(self, pmod_index=0, width=16, hardware_r33=True): 24 | self.pmod_index = pmod_index 25 | self.width = width 26 | self.hardware_r33 = hardware_r33 27 | 28 | self.cal_in0 = Signal(signed(width)) 29 | self.cal_in1 = Signal(signed(width)) 30 | self.cal_in2 = Signal(signed(width)) 31 | self.cal_in3 = Signal(signed(width)) 32 | 33 | self.cal_out0 = Signal(signed(width)) 34 | self.cal_out1 = Signal(signed(width)) 35 | self.cal_out2 = Signal(signed(width)) 36 | self.cal_out3 = Signal(signed(width)) 37 | 38 | self.eeprom_mfg = Signal(8) 39 | self.eeprom_dev = Signal(8) 40 | self.eeprom_serial = Signal(32) 41 | self.jack = Signal(8) 42 | 43 | self.sample_adc0 = Signal(signed(width)) 44 | self.sample_adc1 = Signal(signed(width)) 45 | self.sample_adc2 = Signal(signed(width)) 46 | self.sample_adc3 = Signal(signed(width)) 47 | 48 | self.force_dac_output = Signal(signed(width)) 49 | 50 | self.fs_strobe = Signal() 51 | 52 | def request_pins(self, platform): 53 | eurorack_pmod = [ 54 | Resource(f"eurorack_pmod{self.pmod_index}", 0, 55 | Subsignal("sdin1", Pins("1", conn=("pmod", self.pmod_index), dir='o')), 56 | Subsignal("sdout1", Pins("2", conn=("pmod", self.pmod_index), dir='i')), 57 | Subsignal("lrck", Pins("3", conn=("pmod", self.pmod_index), dir='o')), 58 | Subsignal("bick", Pins("4", conn=("pmod", self.pmod_index), dir='o')), 59 | Subsignal("mclk", Pins("10", conn=("pmod", self.pmod_index), dir='o')), 60 | Subsignal("pdn", Pins("9", conn=("pmod", self.pmod_index), dir='o')), 61 | Subsignal("i2c_sda", Pins("8", conn=("pmod", self.pmod_index), dir='io')), 62 | Subsignal("i2c_scl", Pins("7", conn=("pmod", self.pmod_index), dir='io')), 63 | Attrs(IO_TYPE="LVCMOS33"), 64 | ) 65 | ] 66 | platform.add_resources(eurorack_pmod) 67 | return platform.request(f"eurorack_pmod{self.pmod_index}") 68 | 69 | def add_verilog_sources(self, platform): 70 | 71 | # 72 | # Verilog sources from `eurorack-pmod` project. 73 | # 74 | # Assumes `eurorack-pmod` repo is checked out in this directory and 75 | # `git submodule update --init` has been run! 76 | # 77 | 78 | vroot = os.path.join(os.path.dirname(os.path.realpath(__file__)), 79 | "../deps/eurorack-pmod/gateware") 80 | 81 | # Defines and default cal for PMOD hardware version. 82 | if self.hardware_r33: 83 | platform.add_file("eurorack_pmod_defines.sv", "`define HW_R33") 84 | platform.add_file("cal/cal_mem_default_r33.hex", 85 | open(os.path.join(vroot, "cal/cal_mem_default_r33.hex"))) 86 | else: 87 | platform.add_file("eurorack_pmod_defines.sv", "`define HW_R31") 88 | platform.add_file("cal/cal_mem_default_r31.hex", 89 | open(os.path.join(vroot, "cal/cal_mem_default_r31.hex"))) 90 | 91 | # Verilog implementation 92 | platform.add_file("eurorack_pmod.sv", open(os.path.join(vroot, "eurorack_pmod.sv"))) 93 | platform.add_file("pmod_i2c_master.sv", open(os.path.join(vroot, "drivers/pmod_i2c_master.sv"))) 94 | platform.add_file("ak4619.sv", open(os.path.join(vroot, "drivers/ak4619.sv"))) 95 | platform.add_file("cal.sv", open(os.path.join(vroot, "cal/cal.sv"))) 96 | platform.add_file("i2c_master.sv", open(os.path.join(vroot, "external/no2misc/rtl/i2c_master.v"))) 97 | 98 | # .hex files for I2C initialization 99 | platform.add_file("drivers/ak4619-cfg.hex", 100 | open(os.path.join(vroot, "drivers/ak4619-cfg.hex"))) 101 | platform.add_file("drivers/pca9635-cfg.hex", 102 | open(os.path.join(vroot, "drivers/pca9635-cfg.hex"))) 103 | 104 | def elaborate(self, platform) -> Module: 105 | 106 | m = Module() 107 | 108 | self.add_verilog_sources(platform) 109 | 110 | self.pmod_pins = pmod_pins = self.request_pins(platform) 111 | 112 | # 1/256 clk_fs divider. this is not a true clock domain, don't create one. 113 | # FIXME: this should be removed from `eurorack-pmod` verilog implementation 114 | # and just replaced with a strobe. that's all its used for anyway. For this 115 | # reason we do NOT expose this signal and only the 'strobe' version created next. 116 | clk_fs = Signal() 117 | clkdiv_fs = Signal(8) 118 | m.d.audio += clkdiv_fs.eq(clkdiv_fs+1) 119 | m.d.comb += clk_fs.eq(clkdiv_fs[-1]) 120 | 121 | # Create a strobe from the sample clock 'clk_fs` that asserts for 1 cycle 122 | # per sample in the 'audio' domain. This is useful for latching our samples 123 | # and hooking up to various signals in our FIFOs external to this module. 124 | m.submodules.fs_edge = fs_edge = DomainRenamer("audio")(EdgeToPulse()) 125 | m.d.audio += fs_edge.edge_in.eq(clk_fs), 126 | m.d.comb += self.fs_strobe.eq(fs_edge.pulse_out) 127 | 128 | # When i2c oe is asserted, we always want to pull down. 129 | m.d.comb += [ 130 | pmod_pins.i2c_scl.o.eq(0), 131 | pmod_pins.i2c_sda.o.eq(0), 132 | ] 133 | 134 | m.submodules.veurorack_pmod = Instance("eurorack_pmod", 135 | # Parameters 136 | p_W = self.width, 137 | 138 | # Ports (clk + reset) 139 | i_clk_256fs = ClockSignal("audio"), 140 | i_clk_fs = clk_fs, #FIXME: deprecate 141 | i_rst = ResetSignal("audio"), 142 | 143 | # Pads (tristate, require different logic to hook these 144 | # up to pads depending on the target platform). 145 | o_i2c_scl_oe = pmod_pins.i2c_scl.oe, 146 | i_i2c_scl_i = pmod_pins.i2c_scl.i, 147 | o_i2c_sda_oe = pmod_pins.i2c_sda.oe, 148 | i_i2c_sda_i = pmod_pins.i2c_sda.i, 149 | 150 | # Pads (directly hooked up to pads without extra logic required) 151 | o_pdn = pmod_pins.pdn.o, 152 | o_mclk = pmod_pins.mclk.o, 153 | o_sdin1 = pmod_pins.sdin1.o, 154 | i_sdout1 = pmod_pins.sdout1.i, 155 | o_lrck = pmod_pins.lrck.o, 156 | o_bick = pmod_pins.bick.o, 157 | 158 | # Ports (clock at clk_fs) 159 | o_cal_in0 = self.cal_in0, 160 | o_cal_in1 = self.cal_in1, 161 | o_cal_in2 = self.cal_in2, 162 | o_cal_in3 = self.cal_in3, 163 | i_cal_out0 = self.cal_out0, 164 | i_cal_out1 = self.cal_out1, 165 | i_cal_out2 = self.cal_out2, 166 | i_cal_out3 = self.cal_out3, 167 | 168 | # Ports (serialized data fetched over I2C) 169 | o_eeprom_mfg = self.eeprom_mfg, 170 | o_eeprom_dev = self.eeprom_dev, 171 | o_eeprom_serial = self.eeprom_serial, 172 | o_jack = self.jack, 173 | 174 | # Debug ports 175 | o_sample_adc0 = self.sample_adc0, 176 | o_sample_adc1 = self.sample_adc1, 177 | o_sample_adc2 = self.sample_adc2, 178 | o_sample_adc3 = self.sample_adc3, 179 | i_force_dac_output = self.force_dac_output, 180 | ) 181 | 182 | return m 183 | -------------------------------------------------------------------------------- /rtl/top.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021 Hans Baier 2 | # Copyright (c) 2024 Seb Holzapfel 3 | # 4 | # SPDX-License-Identifier: BSD--3-Clause 5 | 6 | import os 7 | 8 | from amaranth import * 9 | from amaranth.build import * 10 | from amaranth.lib.cdc import FFSynchronizer 11 | from amaranth.lib.fifo import SyncFIFO, AsyncFIFO 12 | 13 | from luna import top_level_cli 14 | from luna.usb2 import USBDevice, USBIsochronousInMemoryEndpoint, USBIsochronousOutStreamEndpoint, USBIsochronousInStreamEndpoint 15 | 16 | from usb_protocol.types import USBRequestType, USBRequestRecipient, USBTransferType, USBSynchronizationType, USBUsageType, USBDirection, USBStandardRequests 17 | from usb_protocol.types.descriptors.uac2 import AudioClassSpecificRequestCodes 18 | from usb_protocol.emitters import DeviceDescriptorCollection 19 | from usb_protocol.emitters.descriptors import uac2, standard 20 | 21 | from luna.gateware.platform import NullPin 22 | from luna.gateware.usb.usb2.device import USBDevice 23 | from luna.gateware.usb.usb2.request import USBRequestHandler, StallOnlyRequestHandler 24 | from luna.gateware.usb.stream import USBInStreamInterface 25 | from luna.gateware.stream.generator import StreamSerializer 26 | from luna.gateware.stream import StreamInterface 27 | from luna.gateware.architecture.car import PHYResetController 28 | 29 | from util import EdgeToPulse 30 | from usb_stream_to_channels import USBStreamToChannels 31 | from channels_to_usb_stream import ChannelsToUSBStream 32 | from eurorack_pmod import EurorackPmod 33 | from audio_to_channels import AudioToChannels 34 | 35 | class USB2AudioInterface(Elaboratable): 36 | """ USB Audio Class v2 interface """ 37 | NR_CHANNELS = 4 38 | MAX_PACKET_SIZE = 512 # NR_CHANNELS * 24 + 4 39 | USE_ILA = False 40 | ILA_MAX_PACKET_SIZE = 512 41 | 42 | def create_descriptors(self): 43 | """ Creates the descriptors that describe our audio topology. """ 44 | 45 | descriptors = DeviceDescriptorCollection() 46 | 47 | with descriptors.DeviceDescriptor() as d: 48 | d.bcdUSB = 2.00 49 | d.bDeviceClass = 0xEF 50 | d.bDeviceSubclass = 0x02 51 | d.bDeviceProtocol = 0x01 52 | d.idVendor = 0x1209 53 | d.idProduct = 0x1234 54 | 55 | d.iManufacturer = "ApfelAudio" 56 | d.iProduct = "PMODface" 57 | d.iSerialNumber = "1234" 58 | d.bcdDevice = 0.01 59 | 60 | d.bNumConfigurations = 1 61 | 62 | with descriptors.ConfigurationDescriptor() as configDescr: 63 | # Interface Association 64 | interfaceAssociationDescriptor = uac2.InterfaceAssociationDescriptorEmitter() 65 | interfaceAssociationDescriptor.bInterfaceCount = 3 # Audio Control + Inputs + Outputs 66 | configDescr.add_subordinate_descriptor(interfaceAssociationDescriptor) 67 | 68 | # Interface Descriptor (Control) 69 | interfaceDescriptor = uac2.StandardAudioControlInterfaceDescriptorEmitter() 70 | interfaceDescriptor.bInterfaceNumber = 0 71 | configDescr.add_subordinate_descriptor(interfaceDescriptor) 72 | 73 | # AudioControl Interface Descriptor 74 | audioControlInterface = self.create_audio_control_interface_descriptor() 75 | configDescr.add_subordinate_descriptor(audioControlInterface) 76 | 77 | self.create_output_channels_descriptor(configDescr) 78 | 79 | self.create_input_channels_descriptor(configDescr) 80 | 81 | if self.USE_ILA: 82 | with configDescr.InterfaceDescriptor() as i: 83 | i.bInterfaceNumber = 3 84 | 85 | with i.EndpointDescriptor() as e: 86 | e.bEndpointAddress = USBDirection.IN.to_endpoint_address(3) # EP 3 IN 87 | e.wMaxPacketSize = self.ILA_MAX_PACKET_SIZE 88 | 89 | return descriptors 90 | 91 | 92 | def create_audio_control_interface_descriptor(self): 93 | audioControlInterface = uac2.ClassSpecificAudioControlInterfaceDescriptorEmitter() 94 | 95 | # AudioControl Interface Descriptor (ClockSource) 96 | clockSource = uac2.ClockSourceDescriptorEmitter() 97 | clockSource.bClockID = 1 98 | clockSource.bmAttributes = uac2.ClockAttributes.INTERNAL_FIXED_CLOCK 99 | clockSource.bmControls = uac2.ClockFrequencyControl.HOST_READ_ONLY 100 | audioControlInterface.add_subordinate_descriptor(clockSource) 101 | 102 | 103 | # streaming input port from the host to the USB interface 104 | inputTerminal = uac2.InputTerminalDescriptorEmitter() 105 | inputTerminal.bTerminalID = 2 106 | inputTerminal.wTerminalType = uac2.USBTerminalTypes.USB_STREAMING 107 | # The number of channels needs to be 2 here in order to be recognized 108 | # default audio out device by Windows. We provide an alternate 109 | # setting with the full channel count, which also references 110 | # this terminal ID 111 | inputTerminal.bNrChannels = self.NR_CHANNELS 112 | inputTerminal.bCSourceID = 1 113 | audioControlInterface.add_subordinate_descriptor(inputTerminal) 114 | 115 | # audio output port from the USB interface to the outside world 116 | outputTerminal = uac2.OutputTerminalDescriptorEmitter() 117 | outputTerminal.bTerminalID = 3 118 | outputTerminal.wTerminalType = uac2.OutputTerminalTypes.SPEAKER 119 | outputTerminal.bSourceID = 2 120 | outputTerminal.bCSourceID = 1 121 | audioControlInterface.add_subordinate_descriptor(outputTerminal) 122 | 123 | # audio input port from the outside world to the USB interface 124 | inputTerminal = uac2.InputTerminalDescriptorEmitter() 125 | inputTerminal.bTerminalID = 4 126 | inputTerminal.wTerminalType = uac2.InputTerminalTypes.MICROPHONE 127 | inputTerminal.bNrChannels = self.NR_CHANNELS 128 | inputTerminal.bCSourceID = 1 129 | audioControlInterface.add_subordinate_descriptor(inputTerminal) 130 | 131 | # audio output port from the USB interface to the host 132 | outputTerminal = uac2.OutputTerminalDescriptorEmitter() 133 | outputTerminal.bTerminalID = 5 134 | outputTerminal.wTerminalType = uac2.USBTerminalTypes.USB_STREAMING 135 | outputTerminal.bSourceID = 4 136 | outputTerminal.bCSourceID = 1 137 | audioControlInterface.add_subordinate_descriptor(outputTerminal) 138 | 139 | return audioControlInterface 140 | 141 | 142 | def create_output_streaming_interface(self, c, *, nr_channels, alt_setting_nr): 143 | # Interface Descriptor (Streaming, OUT, active setting) 144 | activeAudioStreamingInterface = uac2.AudioStreamingInterfaceDescriptorEmitter() 145 | activeAudioStreamingInterface.bInterfaceNumber = 1 146 | activeAudioStreamingInterface.bAlternateSetting = alt_setting_nr 147 | activeAudioStreamingInterface.bNumEndpoints = 2 148 | c.add_subordinate_descriptor(activeAudioStreamingInterface) 149 | 150 | # AudioStreaming Interface Descriptor (General) 151 | audioStreamingInterface = uac2.ClassSpecificAudioStreamingInterfaceDescriptorEmitter() 152 | audioStreamingInterface.bTerminalLink = 2 153 | audioStreamingInterface.bFormatType = uac2.FormatTypes.FORMAT_TYPE_I 154 | audioStreamingInterface.bmFormats = uac2.TypeIFormats.PCM 155 | audioStreamingInterface.bNrChannels = nr_channels 156 | c.add_subordinate_descriptor(audioStreamingInterface) 157 | 158 | # AudioStreaming Interface Descriptor (Type I) 159 | typeIStreamingInterface = uac2.TypeIFormatTypeDescriptorEmitter() 160 | typeIStreamingInterface.bSubslotSize = 4 161 | typeIStreamingInterface.bBitResolution = 24 # we use all 24 bits 162 | c.add_subordinate_descriptor(typeIStreamingInterface) 163 | 164 | # Endpoint Descriptor (Audio out) 165 | audioOutEndpoint = standard.EndpointDescriptorEmitter() 166 | audioOutEndpoint.bEndpointAddress = USBDirection.OUT.to_endpoint_address(1) # EP 1 OUT 167 | audioOutEndpoint.bmAttributes = USBTransferType.ISOCHRONOUS | \ 168 | (USBSynchronizationType.ASYNC << 2) | \ 169 | (USBUsageType.DATA << 4) 170 | audioOutEndpoint.wMaxPacketSize = self.MAX_PACKET_SIZE 171 | audioOutEndpoint.bInterval = 1 172 | c.add_subordinate_descriptor(audioOutEndpoint) 173 | 174 | # AudioControl Endpoint Descriptor 175 | audioControlEndpoint = uac2.ClassSpecificAudioStreamingIsochronousAudioDataEndpointDescriptorEmitter() 176 | c.add_subordinate_descriptor(audioControlEndpoint) 177 | 178 | # Endpoint Descriptor (Feedback IN) 179 | feedbackInEndpoint = standard.EndpointDescriptorEmitter() 180 | feedbackInEndpoint.bEndpointAddress = USBDirection.IN.to_endpoint_address(1) # EP 1 IN 181 | feedbackInEndpoint.bmAttributes = USBTransferType.ISOCHRONOUS | \ 182 | (USBSynchronizationType.NONE << 2) | \ 183 | (USBUsageType.FEEDBACK << 4) 184 | feedbackInEndpoint.wMaxPacketSize = 4 185 | feedbackInEndpoint.bInterval = 4 186 | c.add_subordinate_descriptor(feedbackInEndpoint) 187 | 188 | 189 | def create_output_channels_descriptor(self, c): 190 | # 191 | # Interface Descriptor (Streaming, OUT, quiet setting) 192 | # 193 | quietAudioStreamingInterface = uac2.AudioStreamingInterfaceDescriptorEmitter() 194 | quietAudioStreamingInterface.bInterfaceNumber = 1 195 | quietAudioStreamingInterface.bAlternateSetting = 0 196 | c.add_subordinate_descriptor(quietAudioStreamingInterface) 197 | 198 | # we need the default alternate setting to be stereo 199 | # out for windows to automatically recognize 200 | # and use this audio interface 201 | self.create_output_streaming_interface(c, nr_channels=self.NR_CHANNELS, alt_setting_nr=1) 202 | 203 | 204 | def create_input_streaming_interface(self, c, *, nr_channels, alt_setting_nr, channel_config=0): 205 | # Interface Descriptor (Streaming, IN, active setting) 206 | activeAudioStreamingInterface = uac2.AudioStreamingInterfaceDescriptorEmitter() 207 | activeAudioStreamingInterface.bInterfaceNumber = 2 208 | activeAudioStreamingInterface.bAlternateSetting = alt_setting_nr 209 | activeAudioStreamingInterface.bNumEndpoints = 1 210 | c.add_subordinate_descriptor(activeAudioStreamingInterface) 211 | 212 | # AudioStreaming Interface Descriptor (General) 213 | audioStreamingInterface = uac2.ClassSpecificAudioStreamingInterfaceDescriptorEmitter() 214 | audioStreamingInterface.bTerminalLink = 5 215 | audioStreamingInterface.bFormatType = uac2.FormatTypes.FORMAT_TYPE_I 216 | audioStreamingInterface.bmFormats = uac2.TypeIFormats.PCM 217 | audioStreamingInterface.bNrChannels = nr_channels 218 | audioStreamingInterface.bmChannelConfig = channel_config 219 | c.add_subordinate_descriptor(audioStreamingInterface) 220 | 221 | # AudioStreaming Interface Descriptor (Type I) 222 | typeIStreamingInterface = uac2.TypeIFormatTypeDescriptorEmitter() 223 | typeIStreamingInterface.bSubslotSize = 4 224 | typeIStreamingInterface.bBitResolution = 24 # we use all 24 bits 225 | c.add_subordinate_descriptor(typeIStreamingInterface) 226 | 227 | # Endpoint Descriptor (Audio out) 228 | audioOutEndpoint = standard.EndpointDescriptorEmitter() 229 | audioOutEndpoint.bEndpointAddress = USBDirection.IN.to_endpoint_address(2) # EP 2 IN 230 | audioOutEndpoint.bmAttributes = USBTransferType.ISOCHRONOUS | \ 231 | (USBSynchronizationType.ASYNC << 2) | \ 232 | (USBUsageType.DATA << 4) 233 | audioOutEndpoint.wMaxPacketSize = self.MAX_PACKET_SIZE 234 | audioOutEndpoint.bInterval = 1 235 | c.add_subordinate_descriptor(audioOutEndpoint) 236 | 237 | # AudioControl Endpoint Descriptor 238 | audioControlEndpoint = uac2.ClassSpecificAudioStreamingIsochronousAudioDataEndpointDescriptorEmitter() 239 | c.add_subordinate_descriptor(audioControlEndpoint) 240 | 241 | 242 | def create_input_channels_descriptor(self, c): 243 | # 244 | # Interface Descriptor (Streaming, IN, quiet setting) 245 | # 246 | quietAudioStreamingInterface = uac2.AudioStreamingInterfaceDescriptorEmitter() 247 | quietAudioStreamingInterface.bInterfaceNumber = 2 248 | quietAudioStreamingInterface.bAlternateSetting = 0 249 | c.add_subordinate_descriptor(quietAudioStreamingInterface) 250 | 251 | # Windows wants a stereo pair as default setting, so let's have it 252 | self.create_input_streaming_interface(c, nr_channels=self.NR_CHANNELS, alt_setting_nr=1, channel_config=0x3) 253 | 254 | def elaborate(self, platform): 255 | m = Module() 256 | 257 | m.submodules.car = platform.clock_domain_generator() 258 | 259 | ulpi = platform.request(platform.default_usb_connection) 260 | m.submodules.usb = usb = USBDevice(bus=ulpi) 261 | 262 | # Add our standard control endpoint to the device. 263 | descriptors = self.create_descriptors() 264 | control_ep = usb.add_control_endpoint() 265 | control_ep.add_standard_request_handlers(descriptors, blacklist=[ 266 | lambda setup: (setup.type == USBRequestType.STANDARD) 267 | & (setup.request == USBStandardRequests.SET_INTERFACE) 268 | ]) 269 | 270 | # Attach our class request handlers. 271 | class_request_handler = UAC2RequestHandlers() 272 | control_ep.add_request_handler(class_request_handler) 273 | 274 | # Attach class-request handlers that stall any vendor or reserved requests, 275 | # as we don't have or need any. 276 | stall_condition = lambda setup : \ 277 | (setup.type == USBRequestType.VENDOR) | \ 278 | (setup.type == USBRequestType.RESERVED) 279 | control_ep.add_request_handler(StallOnlyRequestHandler(stall_condition)) 280 | 281 | ep1_out = USBIsochronousOutStreamEndpoint( 282 | endpoint_number=1, # EP 1 OUT 283 | max_packet_size=self.MAX_PACKET_SIZE) 284 | usb.add_endpoint(ep1_out) 285 | 286 | ep1_in = USBIsochronousInMemoryEndpoint( 287 | endpoint_number=1, # EP 1 IN 288 | max_packet_size=4) 289 | usb.add_endpoint(ep1_in) 290 | 291 | ep2_in = USBIsochronousInStreamEndpoint( 292 | endpoint_number=2, # EP 2 IN 293 | max_packet_size=self.MAX_PACKET_SIZE) 294 | usb.add_endpoint(ep2_in) 295 | 296 | # calculate bytes in frame for audio in 297 | audio_in_frame_bytes = Signal(range(self.MAX_PACKET_SIZE), reset=24 * self.NR_CHANNELS) 298 | audio_in_frame_bytes_counting = Signal() 299 | 300 | with m.If(ep1_out.stream.valid & ep1_out.stream.ready): 301 | with m.If(audio_in_frame_bytes_counting): 302 | m.d.usb += audio_in_frame_bytes.eq(audio_in_frame_bytes + 1) 303 | 304 | with m.If(ep1_out.stream.first): 305 | m.d.usb += [ 306 | audio_in_frame_bytes.eq(1), 307 | audio_in_frame_bytes_counting.eq(1), 308 | ] 309 | with m.Elif(ep1_out.stream.last): 310 | m.d.usb += audio_in_frame_bytes_counting.eq(0) 311 | 312 | # Connect our device as a high speed device 313 | m.d.comb += [ 314 | ep1_in.bytes_in_frame.eq(4), 315 | ep2_in.bytes_in_frame.eq(audio_in_frame_bytes), 316 | usb.connect .eq(1), 317 | usb.full_speed_only .eq(0), 318 | ] 319 | 320 | # feedback endpoint 321 | feedbackValue = Signal(32, reset=0x60000) 322 | bitPos = Signal(5) 323 | 324 | # this tracks the number of audio frames since the last USB frame 325 | # 12.288MHz / 8kHz = 1536, so we need at least 11 bits = 2048 326 | # we need to capture 32 micro frames to get to the precision 327 | # required by the USB standard, so and that is 0xc000, so we 328 | # need 16 bits here 329 | audio_clock_counter = Signal(16) 330 | sof_counter = Signal(5) 331 | 332 | audio_clock_usb = Signal() 333 | m.submodules.audio_clock_usb_sync = FFSynchronizer(ClockSignal("audio"), audio_clock_usb, o_domain="usb") 334 | m.submodules.audio_clock_usb_pulse = audio_clock_usb_pulse = DomainRenamer("usb")(EdgeToPulse()) 335 | audio_clock_tick = Signal() 336 | m.d.usb += [ 337 | audio_clock_usb_pulse.edge_in.eq(audio_clock_usb), 338 | audio_clock_tick.eq(audio_clock_usb_pulse.pulse_out), 339 | ] 340 | 341 | with m.If(audio_clock_tick): 342 | m.d.usb += audio_clock_counter.eq(audio_clock_counter + 1) 343 | 344 | with m.If(usb.sof_detected): 345 | m.d.usb += sof_counter.eq(sof_counter + 1) 346 | 347 | # according to USB2 standard chapter 5.12.4.2 348 | # we need 2**13 / 2**8 = 2**5 = 32 SOF-frames of 349 | # sample master frequency counter to get enough 350 | # precision for the sample frequency estimate 351 | # / 2**8 because the ADAT-clock = 256 times = 2**8 352 | # the sample frequency and sof_counter is 5 bits 353 | # so it wraps automatically every 32 SOFs 354 | with m.If(sof_counter == 0): 355 | m.d.usb += [ 356 | feedbackValue.eq(audio_clock_counter << 3), 357 | audio_clock_counter.eq(0), 358 | ] 359 | 360 | m.d.comb += [ 361 | bitPos.eq(ep1_in.address << 3), 362 | ep1_in.value.eq(0xff & (feedbackValue >> bitPos)), 363 | ] 364 | 365 | m.submodules.usb_to_channel_stream = usb_to_channel_stream = \ 366 | DomainRenamer("usb")(USBStreamToChannels(self.NR_CHANNELS)) 367 | 368 | m.submodules.channels_to_usb_stream = channels_to_usb_stream = \ 369 | DomainRenamer("usb")(ChannelsToUSBStream(self.NR_CHANNELS)) 370 | 371 | m.d.comb += [ 372 | # Wire USB <-> stream synchronizers 373 | usb_to_channel_stream.usb_stream_in.stream_eq(ep1_out.stream), 374 | ep2_in.stream.stream_eq(channels_to_usb_stream.usb_stream_out), 375 | ] 376 | 377 | m.submodules.eurorack_pmod = eurorack_pmod = EurorackPmod( 378 | hardware_r33=(os.getenv('PMOD_HW') == 'HW_R33')) 379 | 380 | m.submodules.audio_to_channels = AudioToChannels( 381 | eurorack_pmod, 382 | to_usb_stream=channels_to_usb_stream.channel_stream_in, 383 | from_usb_stream=usb_to_channel_stream.channel_stream_out) 384 | 385 | return m 386 | 387 | class UAC2RequestHandlers(USBRequestHandler): 388 | """ request handlers to implement UAC2 functionality. """ 389 | def __init__(self): 390 | super().__init__() 391 | 392 | self.output_interface_altsetting_nr = Signal(3) 393 | self.input_interface_altsetting_nr = Signal(3) 394 | self.interface_settings_changed = Signal() 395 | 396 | def elaborate(self, platform): 397 | m = Module() 398 | 399 | interface = self.interface 400 | setup = self.interface.setup 401 | 402 | m.submodules.transmitter = transmitter = \ 403 | StreamSerializer(data_length=14, domain="usb", stream_type=USBInStreamInterface, max_length_width=14) 404 | 405 | m.d.usb += self.interface_settings_changed.eq(0) 406 | 407 | # 408 | # Class request handlers. 409 | # 410 | with m.If(setup.type == USBRequestType.STANDARD): 411 | with m.If((setup.recipient == USBRequestRecipient.INTERFACE) & 412 | (setup.request == USBStandardRequests.SET_INTERFACE)): 413 | 414 | m.d.comb += interface.claim.eq(1) 415 | 416 | interface_nr = setup.index 417 | alt_setting_nr = setup.value 418 | 419 | m.d.usb += [ 420 | self.output_interface_altsetting_nr.eq(0), 421 | self.input_interface_altsetting_nr.eq(0), 422 | self.interface_settings_changed.eq(1), 423 | ] 424 | 425 | with m.Switch(interface_nr): 426 | with m.Case(1): 427 | m.d.usb += self.output_interface_altsetting_nr.eq(alt_setting_nr) 428 | with m.Case(2): 429 | m.d.usb += self.input_interface_altsetting_nr.eq(alt_setting_nr) 430 | 431 | # Always ACK the data out... 432 | with m.If(interface.rx_ready_for_response): 433 | m.d.comb += interface.handshakes_out.ack.eq(1) 434 | 435 | # ... and accept whatever the request was. 436 | with m.If(interface.status_requested): 437 | m.d.comb += self.send_zlp() 438 | 439 | request_clock_freq = (setup.value == 0x100) & (setup.index == 0x0100) 440 | with m.Elif(setup.type == USBRequestType.CLASS): 441 | with m.Switch(setup.request): 442 | with m.Case(AudioClassSpecificRequestCodes.RANGE): 443 | m.d.comb += interface.claim.eq(1) 444 | m.d.comb += transmitter.stream.attach(self.interface.tx) 445 | 446 | with m.If(request_clock_freq): 447 | m.d.comb += [ 448 | Cat(transmitter.data).eq( 449 | Cat(Const(0x1, 16), # no triples 450 | Const(48000, 32), # MIN 451 | Const(48000, 32), # MAX 452 | Const(0, 32))), # RES 453 | transmitter.max_length.eq(setup.length) 454 | ] 455 | with m.Else(): 456 | m.d.comb += interface.handshakes_out.stall.eq(1) 457 | 458 | # ... trigger it to respond when data's requested... 459 | with m.If(interface.data_requested): 460 | m.d.comb += transmitter.start.eq(1) 461 | 462 | # ... and ACK our status stage. 463 | with m.If(interface.status_requested): 464 | m.d.comb += interface.handshakes_out.ack.eq(1) 465 | 466 | with m.Case(AudioClassSpecificRequestCodes.CUR): 467 | m.d.comb += interface.claim.eq(1) 468 | m.d.comb += transmitter.stream.attach(self.interface.tx) 469 | with m.If(request_clock_freq & (setup.length == 4)): 470 | m.d.comb += [ 471 | Cat(transmitter.data[0:4]).eq(Const(48000, 32)), 472 | transmitter.max_length.eq(4) 473 | ] 474 | with m.Else(): 475 | m.d.comb += interface.handshakes_out.stall.eq(1) 476 | 477 | # ... trigger it to respond when data's requested... 478 | with m.If(interface.data_requested): 479 | m.d.comb += transmitter.start.eq(1) 480 | 481 | # ... and ACK our status stage. 482 | with m.If(interface.status_requested): 483 | m.d.comb += interface.handshakes_out.ack.eq(1) 484 | 485 | return m 486 | 487 | if __name__ == "__main__": 488 | if not os.getenv('PMOD_HW') in ['HW_R33', 'HW_R31']: 489 | print('Please specify a valid eurorack-pmod hardware revision in the environment e.g.\n' 490 | '$ PMOD_HW=HW_R33 LUNA_PLAFORM= ...') 491 | else: 492 | top_level_cli(USB2AudioInterface) 493 | --------------------------------------------------------------------------------