RISC-V Synth
Synth
The Synth is a sound chip with eight independent channels, so up to eight sounds can play together. Each channel plays a waveform, the repeating shape of a sound, at a frequency in Hz. An envelope changes its volume over the life of a note. A program controls these settings by storing values into the chip's registers, which sit in memory from 0xFFFF0100. Turning a channel's gate on starts its envelope; turning it off lets the note fade through its release.
The Synth is an extension of this editor and is absent from RARS; for RARS's own sound interface, see services 31 and 33. Its design draws on classic sound chips: the SID's pulse width and envelope, the Game Boy's wavetable and the SNES's sample memory.
This example configures channel 0, plays a pulse note for half a second, then releases it and waits for the fade to finish.
li s0, 0xffff0100 # channel 0
li t0, 440 # A above middle C, in Hz
sh t0, 0(s0) # frequency
li t0, 1024 # a pulse high a quarter of the time
sh t0, 2(s0) # pulse width
li t0, 20
sh t0, 4(s0) # attack: 20 ticks, about 20 ms
li t0, 300
sh t0, 6(s0) # decay
li t0, 400
sh t0, 8(s0) # release
sb zero, 0x11(s0) # waveform 0: pulse
li t0, 100
sb t0, 0x12(s0) # volume
li t0, 64
sb t0, 0x13(s0) # sustain at half
li t0, 1
sb t0, 0x10(s0) # gate on: the note starts
li a7, 32
li a0, 500
ecall # half a second
sb zero, 0x10(s0) # gate off: the note fades
li a0, 500
ecall
li a7, 10
ecall1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
The registers read back what was last written, as memory does; there is no register that answers whether a channel is still sounding. Build silences all eight channels, resets their gates and empties Sample memory. Every register starts at 0. With the bitmap display at its "memory map" base, 0xffff0000, the display's words cover these registers: they are pixels then, and the Synth is off.
Registers
Channel n, from 0 to 7, has 32 bytes at 0xFFFF0100 + n × 0x20. Two-byte registers use the processor's byte order: write them with one sh instruction. Write one-byte registers with sb. The bytes from 0x14 to 0x1F of each channel are reserved.
| Offset | Bits | In C | Register |
|---|---|---|---|
0x00 | 16 | SIM_SYNTH_FREQUENCY | Frequency in Hz, or samples per second for the sample waveform; 0 holds the phase and sample cursor still |
0x02 | 16 | SIM_SYNTH_PULSE_WIDTH | Pulse width, 0 to 4095; 2048 is a square wave, and values above 4095 stay high |
0x04 | 16 | SIM_SYNTH_ATTACK | Ticks of 1/1024 s to rise from silence to full |
0x06 | 16 | SIM_SYNTH_DECAY | Ticks of 1/1024 s to fall the whole way from full to silence, stopping at sustain |
0x08 | 16 | SIM_SYNTH_RELEASE | Ticks of 1/1024 s to fall the whole way from full to silence once the gate is off |
0x0A | 16 | SIM_SYNTH_SAMPLE_START | Absolute starting offset in shared Sample memory |
0x0C | 16 | SIM_SYNTH_SAMPLE_LENGTH | Number of samples to play, wrapping at 65,536; 0 means 65,536 samples |
0x0E | 16 | SIM_SYNTH_LOOP_START | Absolute offset in Sample memory to return to when looping |
0x10 | 8 | SIM_SYNTH_CONTROL | Bit 0 gate: on attacks, off releases; bit 1 loop the sample |
0x11 | 8 | SIM_SYNTH_WAVEFORM | Which waveform the channel plays, from the table of waveforms |
0x12 | 8 | SIM_SYNTH_VOLUME | Volume, 0 to 127; higher values sound as 127 |
0x13 | 8 | SIM_SYNTH_SUSTAIN | Level held while the gate is on after decay, 0 to 127; higher values sound as 127 |
Waveforms
The waveform register picks what a channel plays. At each of 65,536 ticks per second, the chip adds the frequency register to a 16-bit phase counter, which records the position within a waveform cycle. Wraparound past 65,535 completes one cycle. A frequency of 440 therefore gives 440 cycles per second, or 440 Hz. Nothing smooths the waveform's steps, so high notes can alias: frequencies beyond the sampling limit are heard at other pitches.
A frequency of 0 stops the phase counter, including noise updates and the sample playback cursor. It does not turn the gate off or stop the envelope.
| Value | Waveform | Sound |
|---|---|---|
| 0 | pulse | High while the phase counter’s top 12 bits are below the pulse width, low for the rest of the cycle: 2048 is a square wave, and a narrower pulse sounds thinner and buzzier. Widths above 4095 stay high throughout the cycle. |
| 1 | triangle | Rises and falls in straight lines: soft and flute-like, the classic bass. |
| 2 | sawtooth | Rises in a straight line and drops at once: bright and brassy. |
| 3 | sine | A pure tone, read from a 256-entry table. |
| 4 | noise | A 15-bit shift register stepped every time the phase wraps, so the frequency sets how bright the noise is: a few thousand Hz for a drum, tens of thousands for a cymbal. |
| 5 | wavetable | The channel’s 32 programmable samples, played once a period. |
| 6 | sample | Samples from Sample memory, the frequency being the playback rate. |
A value above 6 plays silence.
Envelope
The envelope shapes each note's volume over time. Turning the gate on starts the attack, which rises to full volume; the decay then falls to the sustain level, which holds for as long as the gate stays on. Turning the gate off starts the release, which fades to silence.
- Times are in ticks of 1/1024 s, close to milliseconds. The attack is how long a rise from silence to full takes; the decay and the release are how long a fall the whole way from full to silence would take, so a release from a sustain of half takes half its ticks. A time of 0 is instant.
- Turning the gate on again starts the attack from the current envelope level. Storing a gate that is already on changes nothing: to strike a note again, turn the gate off and on.
- The volume register scales the whole note, 0 to 127. Volume and sustain values above 127 sound as 127; their registers still read back the bytes written.
Wavetables
Each channel has its own wavetable of 32 samples, four bits each, at 0xFFFF0200 + n × 0x10: sixteen bytes, the first sample of each pair in the byte's high four bits. The channel plays the 32 samples once a period, 0 the lowest and 15 the highest, so a wavetable is a waveform of the program's own design.
This setup fragment writes the first four bytes of channel 0's table and selects it. The remaining twelve bytes keep their previous values, initially 0. To hear it, also set volume and envelope, then use the gate-on, wait and gate-off sequence from the first example.
li s0, 0xffff0100
li t0, 0xfffffff0 # little-endian bytes: F0 FF FF FF
sw t0, 0x100(s0) # samples: 15, 0, 15, 15, 15, 15, 15, 15
li t0, 5
sb t0, 0x11(s0) # waveform 5: wavetable1
2
3
4
5Sample memory and uploads
The Synth has one shared 64 KB Sample memory, holding raw PCM (pulse-code modulation) data: signed 8-bit samples, -128 to 127. These bytes are successive sound levels, without a file header such as a WAV header. A wavetable describes one repeating cycle; a sample buffer can hold a finite recording or a sound computed by the program.
To copy a buffer from the program's memory into Sample memory, set the upload source address, length and destination, then write the upload-start register. The copy happens at that instant. Changing the source buffer afterwards does not change Sample memory, so the program can reuse the buffer straight away.
There is one shared upload controller for all eight channels. The following offsets are from the Synth base 0xFFFF0100, not from a channel's address: the source register is at 0xFFFF0100 + 0x180.
| Offset | Bits | In C | Register |
|---|---|---|---|
0x180 | 32 | SIM_SYNTH_UPLOAD_SOURCE | Where the copy comes from, in the program's memory |
0x184 | 16 | SIM_SYNTH_UPLOAD_LENGTH | Number of bytes to copy; 0 means 65,536 |
0x186 | 16 | SIM_SYNTH_UPLOAD_DESTINATION | Destination offset in shared Sample memory; the copy wraps at 65,536 |
0x188 | 8 | SIM_SYNTH_UPLOAD_START | Any program write copies immediately; a memory-view Poke does not copy |
Uploads wrap from Sample-memory offset 65,535 to 0 if destination + length exceeds 65,536. A length of 0 copies 65,536 bytes, starting at the chosen destination and wrapping if needed. An upload must come from the running program: a Poke to the start register in the memory view changes its stored byte but copies nothing.
For playback, select waveform 6 and set the channel's sample start and sample length before turning its gate on. The cursor starts at that offset and plays that many samples, wrapping at the end of Sample memory; length 0 means 65,536 samples. Start and length are captured for this first pass when the gate rises. At the end of a one-shot sample, the cursor stops and the output becomes silent, but the gate bit stays on. Turn it off and on to play the sample again.
With control bit 1 set, playback instead jumps to loop start, an absolute offset in Sample memory. At each jump it reads the current loop start, sample start and sample length registers. It plays from loop start up to the end defined by sample start + length, wrapping through offset 0 if necessary. Even a loop start outside the first pass's region is valid: the loop length is that distance modulo 65,536, with a distance of 0 meaning 65,536 samples.
For this waveform, frequency is the playback rate in samples per second. A sound computed at 8,000 samples per second plays at its original pitch with a frequency of 8,000, and an octave higher with 16,000. Longer sounds can use program-managed buffering: arrange looping and time uploads into the unused part of Sample memory yourself, using the playback rate to calculate each buffer's duration. There is no playback-cursor or completion register.
The following setup fragment assumes a sound buffer containing 8,000 signed sample bytes. It uploads the bytes and selects one second of playback at 8,000 samples per second. To hear it, also set volume and envelope, then turn the gate on and wait as in the first example.
li s0, 0xffff0100
la t0, sound # a buffer of signed 8-bit samples
sw t0, 0x180(s0) # copy from it
li t1, 8000
sh t1, 0x184(s0) # 8,000 bytes
sh zero, 0x186(s0) # to the start of Sample memory
sb zero, 0x188(s0) # now
sh zero, 0xa(s0) # channel 0 sample start: 0
sh t1, 0xc(s0) # for 8,000 samples
li t0, 6
sb t0, 0x11(s0) # waveform 6: sample
sh t1, 0(s0) # playback rate: 8,000 samples per second1
2
3
4
5
6
7
8
9
10
11
12Notes and their frequencies
For repeating waveforms, the frequency register accepts whole Hz from 0 to 65,535; 0 holds the phase still. The audio output's sampling rate also limits audible frequencies. An unsmoothed waveform's higher harmonics can alias even when its fundamental pitch is below that limit.
The table rounds each note of equal temperament to the nearest Hz. Rounding puts no note more than about 11 cents, a ninth of a semitone, off pitch from C2 up, and less than 3 cents from 220 Hz up.
| Note | Octave 2 | Octave 3 | Octave 4 | Octave 5 | Octave 6 |
|---|---|---|---|---|---|
| C | 65 | 131 | 262 | 523 | 1047 |
| C♯ | 69 | 139 | 277 | 554 | 1109 |
| D | 73 | 147 | 294 | 587 | 1175 |
| D♯ | 78 | 156 | 311 | 622 | 1245 |
| E | 82 | 165 | 330 | 659 | 1319 |
| F | 87 | 175 | 349 | 698 | 1397 |
| F♯ | 92 | 185 | 370 | 740 | 1480 |
| G | 98 | 196 | 392 | 784 | 1568 |
| G♯ | 104 | 208 | 415 | 831 | 1661 |
| A | 110 | 220 | 440 | 880 | 1760 |
| A♯ | 117 | 233 | 466 | 932 | 1865 |
| B | 123 | 247 | 494 | 988 | 1976 |
Octave 4 starts at middle C, and its A is 440 Hz. One octave up doubles a frequency.
From C
<sim.h> names every register. SIM_SYNTH8(channel, register) and SIM_SYNTH16(channel, register) read and write a channel's 8-bit and 16-bit registers; SIM_SYNTH_TABLE(channel) points at its wavetable. Waveform constants run from SIM_SYNTH_PULSE to SIM_SYNTH_SAMPLE.
Three helpers cover common operations:
sim_synth_play(channel, hz)sets frequency and turns the gate on. It starts an attack only if the gate was off; to strike another note while it is on, callsim_synth_gate(channel, 0)first.sim_synth_gate(channel, on)turns the gate on or off, starting attack or release when the gate changes.sim_synth_upload(source, length, offset)immediately copies signed 8-bit sample bytes into shared Sample memory. Length 0 means 65,536 bytes, and the destination wraps at 65,536.
See Using C and C++ for compiling. This example plays a C major scale, turning the gate off between notes.
#include <sim.h>
int main(void) {
SIM_SYNTH8(0, SIM_SYNTH_WAVEFORM) = SIM_SYNTH_TRIANGLE;
SIM_SYNTH8(0, SIM_SYNTH_VOLUME) = 100;
SIM_SYNTH8(0, SIM_SYNTH_SUSTAIN) = 127;
SIM_SYNTH16(0, SIM_SYNTH_RELEASE) = 300;
unsigned scale[] = {262, 294, 330, 349, 392, 440, 494, 523};
for (int i = 0; i < 8; i++) {
sim_synth_play(0, scale[i]); /* C major, one note at a time */
sim_sleep(200);
sim_synth_gate(0, 0);
sim_sleep(50);
}
return 0;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16While a program runs
- Pause and Breakpoints. A channel whose gate is on keeps sounding while the program is paused, as a real chip does while a debugger holds the processor.
- The end of the program. Every gate is released when the program ends, so its notes fade through their release rather than sounding forever.
- Undo. The registers are memory, which Undo rewinds, and the Synth plays whatever they hold afterwards: undoing the store that turned a gate off turns it back on, and the note attacks again. Undoing an upload puts back the samples it overwrote.
- Stop and Build silence the Synth at once.
- Testcases are silent. The registers still read back what was written, so a Testcase answers as an interactive run does.
- The Sound button in the execution dock mutes the Synth as it does every other sound, and Sound volume in Preferences sets its volume.