Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion DECISIONS.md

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ Edits (add, remove, split, skip, swap, speed) are applied at the next beat bound
| 3.5 mm headphone | Stereo, with detect. |
| 3.5 mm line out | Stereo, fixed level. |
| 3.5 mm sync in / out | Pocket Operator–compatible pulse clock (in and out on one TRS, PO convention). |
| 3.5 mm MIDI in / out (TRS type A) | MIDI clock and notes. Jam link uses this cable (§11). |
| 3.5 mm MIDI in (TRS type A) | MIDI clock. |
| 3.5 mm MIDI out (TRS type A) | MIDI clock. The jam link uses both jacks and two cables (§11, D-111). |

### 7.7 Power
- 2500 mAh Li-Po, target 20 hours of play on headphones, 10 on speaker.
Expand Down Expand Up @@ -351,12 +352,13 @@ A song is the four section bodies (everything after `RT2:`, without lineage) joi

## 11. Sync and jam

- **Clock out**: PO-style pulse on sync out and MIDI clock on MIDI out, always on while playing (configurable).
- **Clock out**: PO-style pulse on sync out and MIDI clock on MIDI out, always on while playing (configurable). A port being followed is never driven, so a follower still clocks everything downstream of it (D-113).
- **Clock in**: if a pulse or MIDI clock is present on the input, the device follows it; the speed knob then shows "ext".
- **Jam link**: two devices connected by one TRS MIDI cable. The device that pressed play first is the clock. Patterns are exchanged as SysEx containing the share code. Gestures:
- **Jam link**: two devices connected by two TRS MIDI cables, each device's out to the other's in (D-111). The device that pressed play first is the clock: a device that presses play having heard no clock becomes it, and one that presses play while clocks are arriving follows (D-113). Patterns are exchanged as SysEx containing the share code. Gestures:
- Hold show + press a pad: send that pad's track to the other device (it lands on the same pad, replacing, with undo).
- Hold show + press dice: send the whole loop.
- Jam must survive unplugging: each device keeps playing its own parts on its own clock.
- Two linked devices lock cycles, not only beats: the follower counts from the leader's Start and its first cycle begins on the leader's, so play may wait up to one cycle and counts in on play's backlight (D-112).
- Latency between linked devices ≤ 3 ms at the audio output.

---
Expand Down
42 changes: 39 additions & 3 deletions firmware/src/app/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
#include <new>

#include "app/card.h"
#include "app/clock.h"
#include "app/controller.h"
#include "app/jam.h"
#include "app/params.h"
#include "app/scheduler.h"
#include "engine/kits/lofi.h"
Expand Down Expand Up @@ -43,6 +45,7 @@ constexpr uint32_t kFramePeriodUs = 16667; // §7.3: 60 fps
constexpr int64_t kFlashFrames = sound::kSampleRate / 4; // §9.1: 250 ms
constexpr int64_t kLedFlashFrames = sound::kSampleRate / 10; // a pad lights fully for 100 ms after its hit
constexpr int kInputBatch = 32;
constexpr int kMidiReadBatch = 256; // a chunk of the wire a pass; a message spans passes if longer
constexpr int kLineCapacity = 320;
constexpr int kFooterCapacity = 32;
const char* const kTapMarker = "tap"; // the top row while tap tempo waits (§8.2, D-102)
Expand All @@ -56,8 +59,10 @@ sound::SampleBank the_samples;
// scheduler's 30 KB event list and the queues stay with the ordinary statics.
HAL_BULK_MEMORY sound::Engine sound_engine;
HAL_BULK_MEMORY Model the_model(the_kit);
Scheduler scheduler(the_kit);
Clock the_clock;
Scheduler scheduler(the_kit, the_clock);
Controller controller(the_kit);
Jam the_jam;
AudioPath audio;
FiredLog the_fired_log;
uint64_t last_frame_us = 0;
Expand Down Expand Up @@ -95,6 +100,8 @@ struct Frame {
bool tap_tempo;
engine::Fraction playhead;
uint32_t cycle_index;
bool following; // a wire owns the tempo (§11): the ring shows it with `ext`
int measured_bpm; // the followed tempo, for the ring's corner
};
Frame frame;

Expand Down Expand Up @@ -176,7 +183,8 @@ void draw_ring(uint16_t* framebuffer, int64_t position, int bottom) {
ring.cycle_index = frame.cycle_index;
ring.playhead = frame.playhead;
ring.playing = frame.transport;
ring.bpm = frame_state.bpm;
ring.external = frame.following;
ring.bpm = frame.following && frame.measured_bpm > 0 ? frame.measured_bpm : frame_state.bpm;
ring.section = letter_of(frame.current);
ring.song = frame.song;
ring.battery = hal::battery_percent();
Expand Down Expand Up @@ -223,6 +231,8 @@ void draw(uint64_t now_us) {
frame_position = position;
frame.playhead = scheduler.playhead(position);
frame.cycle_index = scheduler.cycle_index();
frame.following = the_clock.following();
frame.measured_bpm = the_clock.measured_bpm();
frame.view = the_model.view;
frame.status = the_model.status;
frame.knob = the_model.knob;
Expand Down Expand Up @@ -334,8 +344,10 @@ void init() {
hal::lock(); // a timer already ticking (the harness re-initialises) cannot see the app half made
new (&sound_engine) sound::Engine();
new (&the_model) Model(the_kit);
new (&scheduler) Scheduler(the_kit);
new (&the_clock) Clock();
new (&scheduler) Scheduler(the_kit, the_clock);
new (&controller) Controller(the_kit);
new (&the_jam) Jam();
audio.reset();
the_fired_log = FiredLog{};
last_frame_us = 0;
Expand All @@ -362,10 +374,20 @@ void tick() {
last_tick_us = now_us;
hal::InputEvent events[kInputBatch];
const int count = hal::read_input(events, kInputBatch);
hal::ClockIn pulses[kClockInDrain]; // drained outside the lock, as input is
const int pulse_count = hal::read_clock_in(pulses, kClockInDrain);
uint8_t midi_in[kMidiReadBatch];
const int midi_count = hal::midi_read(midi_in, kMidiReadBatch);
hal::lock();
// Fold the wire in first: a play release and the pulses that establish following can
// land in one pass, and start() must see this pass's clock, not the last one's (D-112).
the_clock.follow(pulses, pulse_count, now_us, audio); // also latches the audio anchor
for (int i = 0; i < count; ++i) controller.handle(events[i], the_model, scheduler, audio);
controller.tick(now_us, the_model, scheduler, audio);
the_jam.step(the_model, the_kit, now_us, midi_in, midi_count); // send the gesture, apply an arrival
the_clock.set_ports(the_model.settings.midi_clock_out, the_model.settings.sync_out);
hal::unlock();
the_jam.pump_out(); // meter the outgoing message onto the wire, outside the lock (D-114)

Fired fired;
while (audio.fired.pop(fired)) the_fired_log.append(fired);
Expand Down Expand Up @@ -393,4 +415,18 @@ int64_t audio_position() {
return position;
}

bool clock_following() {
hal::lock();
const bool following = the_clock.following();
hal::unlock();
return following;
}

int clock_measured_bpm() {
hal::lock();
const int bpm = the_clock.measured_bpm();
hal::unlock();
return bpm;
}

} // namespace app
3 changes: 3 additions & 0 deletions firmware/src/app/app.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,8 @@ void tick();
const Model& model();
const FiredLog& fired_log();
int64_t audio_position();
// The clock's follow state, for the ring and the tests (read on the main loop).
bool clock_following();
int clock_measured_bpm();

} // namespace app
3 changes: 3 additions & 0 deletions firmware/src/app/audio_path.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ void AudioPath::reset(uint64_t blocks) {
}
sound::Params stale;
params.take(stale);
AudioAnchor old_anchor; // an unclaimed anchor outlives take(), and a test's first must be its own
anchor.take(old_anchor);
audio_blocks_ = blocks;
low_blocks_.store(static_cast<uint32_t>(blocks));
control_blocks_ = blocks;
Expand Down Expand Up @@ -110,6 +112,7 @@ void AudioPath::render(float* left, float* right) {
engine_->render(triggers, count, block_);
std::memcpy(left, block_.left, sizeof block_.left);
std::memcpy(right, block_.right, sizeof block_.right);
anchor.publish(AudioAnchor{block_start, hal::now_us()});
audio_blocks_ += 1;
low_blocks_.store(static_cast<uint32_t>(audio_blocks_), std::memory_order_release);
}
Expand Down
13 changes: 13 additions & 0 deletions firmware/src/app/audio_path.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ struct Immediate {
uint64_t pressed_us; // when the platform saw the press; 0 = do not measure
};

// The two clocks side by side: the first frame of the block the audio side has just
// rendered, and the microsecond it read while rendering it. app/ needs the pair both
// ways — to turn a beat's frame into a deadline for the wire, and an arriving pulse's
// stamp into a frame — and neither clock can answer for the other, since one counts
// samples the codec asked for and the other counts real time. Published once a block;
// the reader latches the newest pair it has seen rather than treating a mailbox that
// answers false as a clock that stopped (D-114).
struct AudioAnchor {
int64_t frames;
uint64_t time_us;
};

// A hit the sound engine was handed, with the frame it started on.
struct Fired {
int64_t sample;
Expand Down Expand Up @@ -87,6 +99,7 @@ class AudioPath {
SpscQueue<Immediate, kImmediateQueueCapacity> immediate;
SpscQueue<Fired, kFiredQueueCapacity> fired;
Mailbox<sound::Params> params;
Mailbox<AudioAnchor> anchor;
std::atomic<uint32_t> live_generation; // written by the scheduler at start and stop

private:
Expand Down
Loading