diff --git a/CHANGELOG.md b/CHANGELOG.md index 432f24e8..bb8424ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ GitHub are its section here (`tools/release.sh` copies them), so add to ## Unreleased +- **Separate STOP and PLAY.** Pressing STOP again while stopped returns + to the loop's start, or to the song's start with the loop off. + +- **Musical typing.** Turn on KEYS in the transport bar to play the + selected track from the computer keyboard. PIANO is one chromatic row. + GRID is four rows in fourths like a Push, in key when a scale is set. + CHORD plays in-key chords on the bottom row under a melody on the rows + above it. With KEYS on, REC records what you play into the open clip + or a new one, layering pass after pass on a loop (docs/40). + +- **Octaves and scale in the piano roll.** A pressed key lights the same + note faintly in every octave on the keys and the grid. In DRAW mode a + ghost shows the note a click would add, with outlines in its other + octaves. A picked scale now marks its keys with a stripe (solid on the + root), greys the keys outside it, and tints its rows in the track's + color. + - **New Project no longer crashes while a reverb rings.** Starting a new project, or opening one, while the old project's master effects were still sounding could crash Slab. diff --git a/docs/40-musical-typing.md b/docs/40-musical-typing.md new file mode 100644 index 00000000..53ea9319 --- /dev/null +++ b/docs/40-musical-typing.md @@ -0,0 +1,100 @@ +# 40 — Musical typing, octave echoes and the scale + +The computer keyboard plays the selected track and records into clips. +The piano roll shows a pressed note in every octave and marks the key's +scale on the keys and the grid. + +Source: `src/ui/musical_keys.zig` (layouts, chords, the recording take), +`src/ui/clip_editor.zig` (echoes, ghost, scale marks), `recordKeys` in +`src/main.zig`, the KEYS tile in `src/ui/transport_bar.zig`. + +## KEYS + +The transport bar's **KEYS** latch turns musical typing on. Beside it: +the layout (PIANO, GRID, CHORD), the octave of the lowest C (C4 = MIDI +60, the piano roll's naming), the velocity (drag; double-click 100) and, +in CHORD, the chord type. + +Notes go to the track of the clip open in the piano roll, or else the +selected track (not a bus or the master), whether or not the clip +editor is open. They start at the KEYS velocity. + +While KEYS is on, its keys belong to it: the panes' unmodified +shortcuts on those keys stand down (D, Q, H, S, Z, E, M, 0, `[` `]`, …). +⌘ combinations, Space, Tab, Enter, Esc, Backspace and the arrows keep +working. A text field, a menu, a dialog or the browser's search takes the +keyboard back, and so does holding ⌘. + +| keys | PIANO | GRID | CHORD | +|---|---|---|---| +| notes | `A W S E D F T G Y H U J K O L P ; '` = C up to F | four rows, see below | see below | +| octave | `Z` `X`, or `-` `=` | `-` `=` | `-` `=` | +| velocity | `C` `V`, or `[` `]` | `[` `]` | `[` `]` | + +Velocity steps through 1, 20, 40, 60, 80, 100, 127. + +### GRID + +The four letter and number rows are pads, bottom row (`Z`…`/`) lowest, +like a Push in fourths. Each row has ten keys. + +- **Scale off:** chromatic, a semitone per key, a fourth (five + semitones) per row up. +- **Scale on:** in key from the root, a scale step per key, three steps + per row up. Every key is in the scale. + +### CHORD + +- **`Z` row:** diatonic chords on degrees 1–10 of the scale, an octave + under the melody. In C major `Z` is C, `X` Dm, `C` Em, `V` F, `B` G … +- **`A` row:** the melody in key, from the root. +- **`Q` row:** the same melody an octave up. +- **`1`–`6`:** chord type: TRIAD, 7TH, SUS2, SUS4, 9TH, POWER. Chords are + stacked in scale steps, so every chord stays in key. + +Hold a chord with the left hand and play the melody over it. With the +scale off, CHORD uses the major scale on the picker's root. + +## Recording + +With KEYS on, **REC** starts the transport and records what you play. +If a track is armed, the audio recorder runs too. + +- **Where notes go:** into the clip open in the piano roll when it's on + the played track and the playhead is inside it (overdub). Otherwise a + new "Take" clip starts at the bar where the first note fell, and the + editor opens it. +- **Clip length:** the clip grows by whole bars of the meter to hold + what's played. +- **While you play:** held notes draw growing in the piano roll, outlined + in red. They're written when you let go. +- **Loops:** when the transport loop wraps, held notes close at the loop + end and start again at the loop start, so a loop builds up pass after + pass. +- **Undo:** each pass is one undo step. +- **Stopping:** REC again, or stopping the transport, ends the pass. Keys + already down when a pass starts aren't recorded. + +Notes keep the beat the frame saw them at (no input-latency +compensation). Quantize (Q) afterwards if you want them on the grid. + +## Octave echoes and the ghost note + +- **Octave echoes:** keys pressed on the keyboard column or the computer + keyboard, and a note being drawn, light their own key and grid row. The + same note in every other octave lights faintly on both, in the track's + color. +- **Ghost note:** in DRAW mode, or with ⌘ held, the note a click would + add shows under the pointer, snapped to the grid and the scale. Its + octaves show as outlines. Drawing a note shows the same outlines. +- **Drum lanes:** a drum lane map has no octaves, so it shows neither. + +## The scale + +With a key and scale picked in the piano roll head: + +- **Grid:** in-key rows are tinted faintly toward the track color, the + root's rows a step more. Out-of-key rows drop slightly below the plain + glass. +- **Keys:** in-key keys get a track-color stripe on their left edge, + solid on the root. Out-of-key white keys go grey. diff --git a/docs/README.md b/docs/README.md index 8ae22c4d..16b3335d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -123,6 +123,9 @@ status and the source before assuming a feature is absent or complete. 40. [39-jp8.md](39-jp8.md) — Juniper-8, the Roland Jupiter-8 from its voice board: expo cross mod, VCO-2 sync, the IR3109's 24/12 dB taps, one shared LFO +41. [40-musical-typing.md](40-musical-typing.md) — the computer keyboard + as PIANO, GRID (Push-style fourths) or CHORD plus melody, recording + into clips, octave echoes, ghost notes and scale marks in the piano roll ## Terminology crib sheet diff --git a/src/engine.zig b/src/engine.zig index 8f9f8d6f..389eae16 100644 --- a/src/engine.zig +++ b/src/engine.zig @@ -33,7 +33,7 @@ const AUD_EV = AUD_Q + 128; const AUD_TAIL_S = 3; /// Hold or release a note on a track, from an editor. -pub const AudEvent = struct { track: u8, pitch: u8, on: bool }; +pub const AudEvent = struct { track: u8, pitch: u8, on: bool, velocity: u8 = 102 }; /// Expression events for a bent note go out every this many samples while /// it sounds (docs/22 §Note expression): 0.67 ms at 48 kHz. pub const EXPR_STEP: u32 = 32; @@ -509,10 +509,16 @@ pub const Engine = struct { /// Start (`on`) or release a held note on a track, playing or not /// (docs/31 §Note editing). UI thread; dropped when the ring is full. pub fn holdNote(self: *Engine, track_idx: usize, pitch: u8, on: bool) void { + self.holdNoteVel(track_idx, pitch, on, 102); + } + + /// `holdNote` at a MIDI velocity (1..127): the computer keyboard's + /// notes play at its VEL (docs/40). + pub fn holdNoteVel(self: *Engine, track_idx: usize, pitch: u8, on: bool, velocity: u8) void { if (track_idx > std.math.maxInt(u8) or pitch > 127) return; const w = self.aud_w.load(.monotonic); if (w -% self.aud_r.load(.acquire) >= AUD_Q) return; - self.aud_q[w % AUD_Q] = .{ .track = @intCast(track_idx), .pitch = pitch, .on = on }; + self.aud_q[w % AUD_Q] = .{ .track = @intCast(track_idx), .pitch = pitch, .on = on, .velocity = velocity }; self.aud_w.store(w +% 1, .release); } @@ -531,13 +537,13 @@ pub const Engine = struct { const was = self.aud_held[word] & bit != 0; if (e.on == was) continue; if (e.on) self.aud_held[word] |= bit else self.aud_held[word] &= ~bit; - self.pushHeld(e.track, e.pitch, e.on); + self.pushHeld(e.track, e.pitch, e.on, e.velocity); } self.aud_r.store(r, .release); if (self.aud_held[0] | self.aud_held[1] != 0 or self.aud_n > 0) self.aud_tail = AUD_TAIL_S * self.transport.sample_rate; } - fn pushHeld(self: *Engine, track: u8, pitch: u8, on: bool) void { + fn pushHeld(self: *Engine, track: u8, pitch: u8, on: bool, velocity: u8) void { if (self.aud_n >= AUD_EV) return; self.aud_ev[self.aud_n] = .{ .sample_offset = 0, @@ -545,7 +551,7 @@ pub const Engine = struct { .channel = 0, .note_id = -1, .pitch = @floatFromInt(pitch), - .velocity = if (on) 0.8 else 0, + .velocity = if (on) @as(f32, @floatFromInt(@min(velocity, 127))) / 127.0 else 0, }; self.aud_ev_track[self.aud_n] = track; self.aud_n += 1; @@ -557,7 +563,7 @@ pub const Engine = struct { for (0..128) |pi| { const bit = @as(u64, 1) << @intCast(pi % 64); if (self.aud_held[pi / 64] & bit == 0) continue; - self.pushHeld(t, @intCast(pi), false); + self.pushHeld(t, @intCast(pi), false, 0); } self.aud_held = .{ 0, 0 }; } @@ -741,7 +747,7 @@ pub const Engine = struct { for (self.tracks) |*t| t.clearSounding(); // Whatever the editor still holds sounds again from here. if (self.aud_track) |at| for (0..128) |pi| { - if (self.aud_held[pi / 64] & (@as(u64, 1) << @intCast(pi % 64)) != 0) self.pushHeld(at, @intCast(pi), true); + if (self.aud_held[pi / 64] & (@as(u64, 1) << @intCast(pi % 64)) != 0) self.pushHeld(at, @intCast(pi), true, 102); }; self.was_playing = false; self.ringing = true; diff --git a/src/main.zig b/src/main.zig index a81b4239..a8bc90dd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -75,6 +75,7 @@ const ui_geom = @import("ui/geom.zig"); const snap_mod = @import("ui/snap.zig"); const arrangement = @import("ui/arrangement.zig"); const clip_editor = @import("ui/clip_editor.zig"); +const musical_keys = @import("ui/musical_keys.zig"); const menu = @import("ui/menu.zig"); const text_field = @import("ui/text_field.zig"); const splash = @import("ui/splash.zig"); @@ -114,6 +115,7 @@ test { _ = @import("ui/timeline.zig"); _ = @import("ui/commands.zig"); _ = @import("ui/clip_editor.zig"); + _ = @import("ui/musical_keys.zig"); _ = @import("fy_host.zig"); _ = @import("meter.zig"); _ = @import("tempo.zig"); @@ -214,8 +216,9 @@ const StatusMessage = struct { /// main starts and releases the difference each frame. const HeldNotes = struct { track: ?usize = null, set: u128 = 0 }; -fn holdNotes(engine: *engine_mod.Engine, held: *HeldNotes, track: ?usize, want_in: u128) void { - const want = if (track == null) 0 else want_in; +/// `typed` are the computer keyboard's notes: they start at its velocity. +fn holdNotes(engine: *engine_mod.Engine, held: *HeldNotes, track: ?usize, want_in: u128, typed: u128, typed_vel: u8) void { + const want = if (track == null) 0 else want_in | typed; if (held.track != track and held.set != 0) { // Another clip: let go of the old track's notes first. if (held.track) |ot| for (0..128) |pi| { @@ -234,7 +237,7 @@ fn holdNotes(engine: *engine_mod.Engine, held: *HeldNotes, track: ?usize, want_i } for (0..128) |pi| { const mask = @as(u128, 1) << @intCast(pi); - if (diff & mask != 0 and want & mask != 0) engine.holdNote(t, @intCast(pi), true); + if (diff & mask != 0 and want & mask != 0) engine.holdNoteVel(t, @intCast(pi), true, if (typed & mask != 0) typed_vel else 102); } held.set = want; } @@ -1128,6 +1131,13 @@ pub fn main(init: std.process.Init) !void { // header, the clip editor's head): docs/31 §Keys. if ((m.left_pressed or m.right_pressed) and !menu.active() and !modal) focus = focusFromPoint(rects, m, layout.clipShown()); + // Musical typing (docs/40) reads the keys first; a text field, menu, + // dialog, the browser's search or ⌘ keeps them. + { + const keys_owned = modal or menu.active() or rename.active() or auto_lane.entryActive() or browser.typing(ui) or + (focus == .browser and layout.browser_visible); + musical_keys.poll(!keys_owned and !commandModifierDown(), clip_editor.keyScale()); + } if (modal) { // Modal: only Esc/Enter act, handled after the dialog draws below. } else if (menu.active()) { @@ -1179,12 +1189,12 @@ pub fn main(init: std.process.Init) !void { if (commandModifierDown() and c.rl.IsKeyPressed(c.rl.KEY_LEFT)) transport.seekToBeats(markers.prev(transport.beats())); if (commandModifierDown() and c.rl.IsKeyPressed(c.rl.KEY_RIGHT)) if (markers.next(transport.beats())) |b| transport.seekToBeats(b); if (!in_browser and c.rl.IsKeyPressed(c.rl.KEY_TAB)) layout.clip_editor_visible = !layout.clip_editor_visible; - if (!in_browser and !commandModifierDown() and c.rl.IsKeyPressed(c.rl.KEY_M)) { + if (!in_browser and !commandModifierDown() and shortcutPressed(c.rl.KEY_M)) { if (shiftDown()) { try executeEditCommand(alloc, &history, &clipboard, &status, focus, edit_snap, .clear_solo_mute, .{}, tracks, &transport, &selected_track, &selected_clip, &rename, &dirty); } else layout.mixer_visible = !layout.mixer_visible; } - if (focus == .piano_roll and !commandModifierDown() and c.rl.IsKeyPressed(c.rl.KEY_E)) clip_editor.toggleExpressionMode(); + if (focus == .piano_roll and !commandModifierDown() and shortcutPressed(c.rl.KEY_E)) clip_editor.toggleExpressionMode(); } c.rl.BeginDrawing(); @@ -1274,8 +1284,8 @@ pub fn main(init: std.process.Init) !void { .project_path = project_path, .project_path_chosen = project_path_chosen, .dirty = dirty, - .recording = rec_busy, - .can_record = audio.capture_available, + .recording = rec_busy or musical_keys.recording, + .can_record = audio.capture_available or musical_keys.enabled, .input_names = input_name_ptrs[0..input_count], .current_input_idx = current_input_idx, .buffer_frames = audio.block_frames, @@ -1351,12 +1361,22 @@ pub fn main(init: std.process.Init) !void { } } if (tres.record_toggle and !rec_finishing) { - if (recorder.isRecording()) { - recorder.requestStop(); - transport.stop(); - rec_finishing = true; + if (recorder.isRecording() or musical_keys.recording) { + if (recorder.isRecording()) { + recorder.requestStop(); + transport.stop(); + rec_finishing = true; + } + musical_keys.recording = false; } else { rec_track = firstArmedAudioTrack(tracks); + // With KEYS on, REC writes what the keyboard plays (docs/40); + // the loop keeps running so passes layer. + if (musical_keys.enabled) { + musical_keys.recording = true; + transport.play(); + if (rec_track == null) status.set("Recording keys\u{2026}", .{}); + } if (rec_track) |ti| { // One pass, in time: a loop would wrap the take over // itself, so it pauses until the take is placed. @@ -1381,8 +1401,8 @@ pub fn main(init: std.process.Init) !void { transport.setLoopEnabled(true); rec_loop_paused = false; } - } else { - status.set("Arm a track (R) to record", .{}); + } else if (!musical_keys.enabled) { + status.set("Arm a track (R) or turn KEYS on to record", .{}); } } } @@ -1686,8 +1706,11 @@ pub fn main(init: std.process.Init) !void { layout.clip_editor_visible = true; rects = layout.compute(sw, sh); } - // The clip editor closed: nothing stays held. - if (!layout.clipShown()) holdNotes(&engine, &held, null, 0); + recordKeys(alloc, &history, tracks, &transport, meter_state.liveMap(), &selected_clip, &selected_track, &dirty); + // The clip editor closed: only the computer keyboard holds notes, + // on the selected track. + const typed = musical_keys.sounding(); + if (!layout.clipShown()) holdNotes(&engine, &held, playableTrack(tracks, selected_track), 0, typed, musical_keys.velocity); if (layout.clipShown()) { const play_beat: ?f64 = if (transport.isPlaying()) transport.beats() else null; const cres = if (selectedClipIsAudio(tracks, selected_clip)) @@ -1704,7 +1727,7 @@ pub fn main(init: std.process.Init) !void { selected_clip = f; selected_track = f.track; } - holdNotes(&engine, &held, if (selected_clip) |s| s.track else null, cres.hear); + holdNotes(&engine, &held, if (selected_clip) |s| s.track else playableTrack(tracks, selected_track), cres.hear, typed, musical_keys.velocity); if (cres.command == .slice_to_sampler) { sliceToSampler(alloc, &history, &status, &engine, &audio, ®, &audio_pool, &tracks_buf, &track_count, &transport, &selected_track, &selected_clip, &dirty); tracks = tracks_buf[0..track_count]; @@ -6739,7 +6762,7 @@ fn handleFocusedEditCommands( // The command table (docs/31 §Keys): the same command a menu runs. if (editWhere(focus, tracks, selected_clip.*)) |where| { - if (commands.pressed(where, gesture.mods(), &c.rl.IsKeyPressed)) |command| { + if (commands.pressed(where, gesture.mods(), &shortcutPressed)) |command| { try executeEditCommand(alloc, history, clipboard, status, focus, edit_snap, command, editCursor(focus, transport), tracks, transport, selected_track, selected_clip, rename, dirty); return; } @@ -7159,12 +7182,123 @@ fn commandModifierDown() bool { c.rl.IsKeyDown(c.rl.KEY_LEFT_CONTROL) or c.rl.IsKeyDown(c.rl.KEY_RIGHT_CONTROL); } +/// The selected track when it can play notes (not a bus or the master). +fn playableTrack(tracks: []track_mod.Track, sel: ?usize) ?usize { + const t = sel orelse return null; + if (t >= tracks.len or tracks[t].kind != .audio) return null; + return t; +} + +/// One frame of recording the computer keyboard (docs/40 §Recording): +/// notes close into the open clip under the playhead on the played track, +/// or a new clip from the bar the pass's first note fell in. The clip +/// grows by bars to hold what's played; one undo step per pass. +fn recordKeys( + alloc: std.mem.Allocator, + history: *history_mod.History, + tracks: []track_mod.Track, + transport: *transport_mod.Transport, + meter: meter_mod.MeterMap, + selected_clip: *?clip_mod.ClipRef, + selected_track: *?usize, + dirty: *bool, +) void { + const take = &musical_keys.take; + var out: [256]musical_keys.RecNote = undefined; + const rolling = musical_keys.recording and transport.isPlaying(); + var n: usize = 0; + if (rolling) { + const beat = transport.beats(); + if (!take.active) take.begin(musical_keys.sounding(), beat); + n = take.update(musical_keys.sounding(), musical_keys.velocity, beat, &out); + } else if (take.active) { + // The transport stopped: the pass ends and REC lets go. + musical_keys.recording = false; + const target = take.target; + n = take.finish(&out); + take.target = target; // kept for the commit below, cleared after + } else return; + if (n == 0 and take.open == 0) { + if (!rolling) take.target = null; + return; + } + const ref = keysTarget(alloc, history, tracks, transport, meter, selected_clip, selected_track, if (n > 0) out[0].start else take.last_beat) orelse { + if (!rolling) take.target = null; + return; + }; + const t = &tracks[ref.track]; + const clip = &t.clips.items[ref.clip]; + const rate = t.time.rate(); + var reach = if (take.open != 0) (take.last_beat - clip.start_beat) * rate else 0; + for (out[0..n]) |rn| { + const start = (rn.start - clip.start_beat) * rate; + if (start < 0) continue; + const len = @max((rn.end - rn.start) * rate, 1.0 / 32.0); + clip.addNote(alloc, .{ .pitch = rn.pitch, .start_beat = start, .length_beats = len, .velocity = @max(rn.velocity, 1) }) catch |err| { + std.log.err("record note failed: {s}", .{@errorName(err)}); + }; + reach = @max(reach, start + len); + } + // Grow by whole bars of the song's meter from the clip's start. + var bar = meter.beatToBarPos(clip.start_beat).bar; + var end = clip.start_beat; + while ((end - clip.start_beat) * rate < reach - 1e-9 or end <= clip.start_beat) : (bar += 1) end += meter.barLenBeats(bar); + const local_end = (end - clip.start_beat) * rate; + if (local_end > clip.length_beats) clip.length_beats = local_end; + dirty.* = true; + if (!rolling) take.target = null; +} + +/// The clip a recording pass writes: the pass's own, the selected one on +/// the played track with the playhead inside it, or a new one. +fn keysTarget( + alloc: std.mem.Allocator, + history: *history_mod.History, + tracks: []track_mod.Track, + transport: *transport_mod.Transport, + meter: meter_mod.MeterMap, + selected_clip: *?clip_mod.ClipRef, + selected_track: *?usize, + beat: f64, +) ?clip_mod.ClipRef { + const take = &musical_keys.take; + if (take.target) |tg| { + if (tg[0] < tracks.len and tg[1] < tracks[tg[0]].clips.items.len) return .{ .track = tg[0], .clip = tg[1] }; + take.target = null; + } + const ti = (if (selected_clip.*) |s| @as(?usize, s.track) else null) orelse playableTrack(tracks, selected_track.*) orelse return null; + if (ti >= tracks.len or tracks[ti].kind != .audio) return null; + pushHistorySnapshot(alloc, history, tracks, transport); + if (selected_clip.*) |s| if (s.track == ti and s.clip < tracks[ti].clips.items.len) { + const cl = &tracks[ti].clips.items[s.clip]; + if (cl.kind == .note and beat >= cl.start_beat and beat < cl.start_beat + cl.length_beats / tracks[ti].time.rate()) { + take.target = .{ s.track, s.clip }; + return s; + } + }; + const pos = meter.beatToBarPos(beat); + const start = meter.barStartBeat(pos.bar); + var buf: [clip_mod.MAX_NAME]u8 = undefined; + const name = std.fmt.bufPrint(&buf, "Take {d}", .{tracks[ti].clips.items.len + 1}) catch "Take"; + tracks[ti].addClip(alloc, clip_mod.Clip.init(name, start, meter.barLenBeats(pos.bar))) catch return null; + const ref = clip_mod.ClipRef{ .track = @intCast(ti), .clip = @intCast(tracks[ti].clips.items.len - 1) }; + take.target = .{ ref.track, ref.clip }; + selected_clip.* = ref; + selected_track.* = ti; + return ref; +} + +/// A pane shortcut's key, unless musical typing has it (docs/40). +fn shortcutPressed(key: c_int) callconv(.c) bool { + return !musical_keys.claims(key) and c.rl.IsKeyPressed(key); +} + fn handleSnapKeys(edit_snap: *snap_mod.Setting, status: *StatusMessage) void { if (commandModifierDown()) return; - if (c.rl.IsKeyPressed(c.rl.KEY_LEFT_BRACKET)) { + if (shortcutPressed(c.rl.KEY_LEFT_BRACKET)) { edit_snap.* = edit_snap.*.coarser(); status.set("Snap {s}", .{std.mem.span(edit_snap.*.label())}); - } else if (c.rl.IsKeyPressed(c.rl.KEY_RIGHT_BRACKET)) { + } else if (shortcutPressed(c.rl.KEY_RIGHT_BRACKET)) { edit_snap.* = edit_snap.*.finer(); status.set("Snap {s}", .{std.mem.span(edit_snap.*.label())}); } diff --git a/src/ui/clip_editor.zig b/src/ui/clip_editor.zig index 0d8610bd..82d148e7 100644 --- a/src/ui/clip_editor.zig +++ b/src/ui/clip_editor.zig @@ -39,6 +39,7 @@ const auto_lane = @import("automation_lane.zig"); const lane_targets = @import("lane_targets.zig"); const timeline = @import("timeline.zig"); const gesture = @import("gesture.zig"); +const musical_keys = @import("musical_keys.zig"); const clip_mod = @import("../clip.zig"); const Clip = clip_mod.Clip; const Note = clip_mod.Note; @@ -139,6 +140,12 @@ const SCALES = [_]Scale{ const ROOT_NAMES = [_][*:0]const u8{ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }; +/// The key for musical typing: the picker's root and scale (docs/40). +pub fn keyScale() musical_keys.Scale { + const si = if (scale_idx < SCALES.len) scale_idx else 0; + return .{ .root = key_root % 12, .mask = SCALES[si].mask, .on = si > 0 }; +} + fn scaleActive() bool { return scale_idx > 0 and scale_idx < SCALES.len and note_map.len == 0; } @@ -333,6 +340,9 @@ fn drawClipBrackets(ui: *Ui, ruler: c.rl.Rectangle, grid: c.rl.Rectangle, tracks } } +/// The recording pass writes the clip on screen: show its open notes. +var rec_here = false; + /// Hear notes as they are pressed and dragged (the HEAR latch). var hear_notes = true; /// The key the keyboard column is held on (a drag glides across keys). @@ -996,6 +1006,7 @@ pub fn draw( ce_tracks = tracks; focus_req = null; gatherShown(tracks, selected.?, resolved.clip); + rec_here = if (musical_keys.take.target) |tg| tg[0] == selected.?.track and tg[1] == selected.?.clip else false; maybeResetOnClipChange(selected, resolved.clip); var body = head.body; const tab_focus = if (shown_n > 1) drawTabs(ui, body.cutTop(TAB_H), tracks, selected.?) else null; @@ -1152,28 +1163,42 @@ fn drawPianoRoll( .loop => |l| loop = .{ clip.start_beat + l[0] / cur_rate, clip.start_beat + l[1] / cur_rate }, }; handleKeyboard(kbd_rect, grid_rect, clip, m); - drawKeyboard(ui, kbd_rect, track.sounding128() | if (kbd_pitch) |kp| @as(u128, 1) << @intCast(kp) else 0, track_color); + // The keys pressed now (column, computer keyboard, a note being + // drawn) and the note the pointer would add echo in every octave. + const in_expr_now = expr_mode and !collapsed(); + const hover = if (in_expr_now) null else hoverGhost(grid_rect, clip.*, edit_snap, m); + var pressed: u128 = musical_keys.sounding(); + if (kbd_pitch) |kp| pressed |= @as(u128, 1) << @intCast(kp); + if (draw_active) pressed |= @as(u128, 1) << @intCast(draw_pitch); + var echo = pitchClasses(pressed); + if (hover) |h| echo |= @as(u12, 1) << @intCast(h.pitch % 12); + if (note_map.len > 0) echo = 0; + drawKeyboard(ui, kbd_rect, track.sounding128() | pressed, echo, track_color); // Everything that scrolls is clipped to the grid viewport so notes and // draw-previews never bleed into the keyboard or the adjacent panes. ui.clip(bridge.fromRl(grid_rect)); - drawGrid(ui, grid_rect, edit_snap); + drawGrid(ui, grid_rect, edit_snap, track_color, if (note_map.len > 0) 0 else pressed, echo); drawGhosts(ui, grid_rect, tracks, clip.*); drawExistingNotes(ui, grid_rect, clip.*, track_color, if (in_clip) local_play else null, track.sounding128()); drawBends(ui, grid_rect, clip.*, track_color, track.machine.takes_expression, m); drawClipEndOverlay(ui, grid_rect, clip.*); + if (rec_here) drawTakePreview(ui, grid_rect, clip.*, track_color); if (draw_active) { const start = @min(draw_start_beat, draw_current_beat); const end = @max(draw_start_beat, draw_current_beat); const len = @max(end - start, minNoteBeats(edit_snap)); - const nr = frectRl(noteRect(grid_rect, .{ - .pitch = draw_pitch, - .start_beat = start, - .length_beats = len, - })); + const ghost = Note{ .pitch = draw_pitch, .start_beat = start, .length_beats = len }; + drawOctaveGhosts(ui, grid_rect, ghost, track_color); + const nr = frectRl(noteRect(grid_rect, ghost)); ui.rect(nr, track_color.mix(ui_style.text, 0.3)); ui.bevel(nr, ui_style.text, ui_style.text); + } else if (hover) |h| { + drawOctaveGhosts(ui, grid_rect, h, track_color); + const nr = frectRl(noteRect(grid_rect, h)); + ui.rect(nr, track_color.alpha(90)); + ui.bevel(nr, track_color.mix(ui_style.text, 0.4), track_color); } drawNoteRange(ui, grid_rect); box.draw(ui, m); @@ -1188,7 +1213,7 @@ fn drawPianoRoll( drawOverview(ui, overview_rect, grid_rect, clip.*, track_color, if (in_clip) local_play else null, m); if (play_beat) |b| drawPlayhead(ui, grid_rect, ruler_rect.y, env_rect.y + env_h, (b - clip.start_beat) * cur_rate, view_span); - const in_expr = expr_mode and !collapsed(); + const in_expr = in_expr_now; if (in_expr and !velocity_consumed) handleExpression(ui, grid_rect, clip, edit_snap, m); if (!velocity_consumed and !in_expr) handleInput(ui, grid_rect, clip, alloc, edit_snap, m); var result = PianoRollResult{ .seek = seek, .loop = loop }; @@ -1371,7 +1396,7 @@ fn drawRuler(ui: *Ui, ruler: c.rl.Rectangle, grid: c.rl.Rectangle, edit_snap: sn /// Key column (hardware): white bed with black-key bars and octave labels /// on the Cs; with a drum note map, labelled lanes on faceplate and unmapped /// rows dark. -fn drawKeyboard(ui: *Ui, r: c.rl.Rectangle, lit: u128, col: ui_style.Color) void { +fn drawKeyboard(ui: *Ui, r: c.rl.Rectangle, lit: u128, echo: u12, col: ui_style.Color) void { const kr = bridge.fromRl(r); ui.clip(kr); defer ui.unclip(); @@ -1407,8 +1432,13 @@ fn drawKeyboard(ui: *Ui, r: c.rl.Rectangle, lit: u128, col: ui_style.Color) void ui.rect(Rect.xywh(kr.x, y, bw, 1), ui_style.key_black.shade(30)); // White-key seam behind the black key's middle. ui.rect(Rect.xywh(kr.x + bw, y + @divFloor(h, 2), kr.w - bw, 1), seam); + scaleMark(ui, kr.x, y, h, pitch, col); } else { const n = pitch % 12; + // Out of the key: the key goes grey, the scale's keys stay + // white (docs/40 §Scale). + if (!inScale(pitch)) ui.rect(Rect.xywh(kr.x, y, kr.w - 1, h), ui_style.key_white.mix(ui_style.face, 0.5)); + scaleMark(ui, kr.x, y, h, pitch, col); // Adjacent white keys (E|F, B|C) meet on a row boundary. if (n == 4 or n == 11) ui.rect(Rect.xywh(kr.x, y, kr.w, 1), seam); if (n == 0) { @@ -1436,16 +1466,99 @@ fn drawKeyboard(ui: *Ui, r: c.rl.Rectangle, lit: u128, col: ui_style.Color) void ui.rect(Rect.xywh(kr.x, y, w, h), col.alpha(200)); } } + // The same note in the other octaves, faintly (docs/40 §Octaves). + if (echo != 0 and !drum) { + var rj: i32 = 0; + while (pitchOfRow(rj)) |pitch| : (rj += 1) { + if (echo & (@as(u12, 1) << @intCast(pitch % 12)) == 0) continue; + if (lit & (@as(u128, 1) << @intCast(pitch)) != 0) continue; + const fy = pitchTopY(r, pitch); + if (fy + view.row_h < r.y or fy > r.y + r.height) continue; + const y = ipx(fy); + const h = ipx(fy + view.row_h) - y; + const w = if (isBlackKey(pitch)) bw else kr.w - 1; + ui.rect(Rect.xywh(kr.x, y, w, h), col.alpha(if (isBlackKey(pitch)) 110 else 80)); + } + } // Seam against the grid. ui.rect(Rect.xywh(kr.right() - 1, kr.y, 1, kr.h), ui_style.edge); } +/// A scale's mark on a key: a track-color stripe down its left edge, +/// solid on the root. +fn scaleMark(ui: *Ui, x: i32, y: i32, h: i32, pitch: u8, col: ui_style.Color) void { + if (!scaleActive() or !inScale(pitch)) return; + if (isRootPitch(pitch)) { + ui.rect(Rect.xywh(x, y, 3, h), col); + } else ui.rect(Rect.xywh(x, y, 2, h), col.alpha(120)); +} + +fn pitchClasses(set: u128) u12 { + var out: u12 = 0; + var s = set; + while (s != 0) { + const p: u8 = @intCast(@ctz(s)); + out |= @as(u12, 1) << @intCast(p % 12); + s &= s - 1; + } + return out; +} + +/// The note a click would add under the pointer (DRAW, or ⌘ held, over +/// empty grid), if any. +fn hoverGhost(grid: c.rl.Rectangle, clip: Clip, edit_snap: snap_mod.Setting, m: pane.Mouse) ?Note { + if (draw_active or pane.hasActiveDrag() or box.active) return null; + if (mode != .draw and !gesture.mods().cmd) return null; + if (!pane.contains(grid, m.x, m.y) or menu.active()) return null; + if (findNoteAt(grid, clip, m.x, m.y) != null) return null; + const pitch = pitchAtY(grid, m.y) orelse return null; + return .{ + .pitch = snapPitchToScale(pitch), + .start_beat = snap_mod.snapDownPositive(edit_snap, beatAtX(grid, m.x), altBypassSnap()), + .length_beats = defaultNoteBeats(edit_snap), + }; +} + +/// Outlines of `n` in every other octave on the roll. +fn drawOctaveGhosts(ui: *Ui, grid: c.rl.Rectangle, n: Note, col: ui_style.Color) void { + if (note_map.len > 0) return; + var p: i32 = @as(i32, n.pitch % 12); + while (p <= KEY_HI) : (p += 12) { + if (p < KEY_LO or p == n.pitch) continue; + var g = n; + g.pitch = @intCast(p); + const nr = frectRl(noteRect(grid, g)); + ui.rect(nr, col.alpha(22)); + ui.bevel(nr, col.alpha(110), col.alpha(110)); + } +} + +/// The notes a recording pass holds open, growing to the playhead. +fn drawTakePreview(ui: *Ui, grid: c.rl.Rectangle, clip: Clip, col: ui_style.Color) void { + const t = &musical_keys.take; + if (!t.active or t.open == 0) return; + for (0..128) |p| { + if (t.open & (@as(u128, 1) << @intCast(p)) == 0) continue; + const s = (t.open_start[p] - clip.start_beat) * cur_rate; + const e = (t.last_beat - clip.start_beat) * cur_rate; + const nr = frectRl(noteRect(grid, .{ .pitch = @intCast(p), .start_beat = s, .length_beats = @max(e - s, 1.0 / 64.0) })); + ui.rect(nr, col); + ui.bevel(nr, ui_style.rec, ui_style.rec); + } +} + /// Note grid (flat glass): lit rows for white keys / in-scale / mapped /// pitches, root rows a step brighter, octave lines under the Cs, then the /// swung snap sub-grid and the meter's beat and bar lines. -fn drawGrid(ui: *Ui, r: c.rl.Rectangle, edit_snap: snap_mod.Setting) void { +fn drawGrid(ui: *Ui, r: c.rl.Rectangle, edit_snap: snap_mod.Setting, col: ui_style.Color, pressed: u128, echo: u12) void { const gr = bridge.fromRl(r); ui.rect(gr, ui_style.pane); + // A scale tints its rows faintly in the track's color, the root's a + // step more; the rest fall back a little (docs/40 §Scale). + const scale_on = scaleActive(); + const in_key = ui_style.pane_alt.mix(col, 0.05); + const root_row = ui_style.pane_alt.mix(col, 0.12); + const off_key = ui_style.pane.shade(-3); var ri: i32 = 0; while (pitchOfRow(ri)) |pitch| : (ri += 1) { @@ -1459,7 +1572,13 @@ fn drawGrid(ui: *Ui, r: c.rl.Rectangle, edit_snap: snap_mod.Setting) void { inScale(pitch) else !isBlackKey(pitch); - if (lit) ui.rect(Rect.xywh(gr.x, y, gr.w, h), if (isRootPitch(pitch)) ui_style.pane_alt.shade(8) else ui_style.pane_alt); + const row = Rect.xywh(gr.x, y, gr.w, h); + if (scale_on) { + ui.rect(row, if (!lit) off_key else if (isRootPitch(pitch)) root_row else in_key); + } else if (lit) ui.rect(row, ui_style.pane_alt); + if (pressed & (@as(u128, 1) << @intCast(pitch)) != 0) { + ui.rect(row, col.alpha(30)); + } else if (echo & (@as(u12, 1) << @intCast(pitch % 12)) != 0) ui.rect(row, col.alpha(14)); if (pitch % 12 == 0 and !collapsed()) ui.rect(Rect.xywh(gr.x, y + h - 1, gr.w, 1), ui_style.grid_beat); if (collapsed()) ui.rect(Rect.xywh(gr.x, y + h - 1, gr.w, 1), ui_style.edge); } diff --git a/src/ui/musical_keys.zig b/src/ui/musical_keys.zig new file mode 100644 index 00000000..ad5ae6a0 --- /dev/null +++ b/src/ui/musical_keys.zig @@ -0,0 +1,415 @@ +//! Musical typing (docs/40): the computer keyboard plays the selected +//! track. PIANO is one chromatic row (A W S E D F T G Y H U J K O L P ; '), +//! GRID is four rows in fourths like a Push (in key when a scale is set), +//! CHORD plays diatonic chords on the bottom row under an in-key melody on +//! the two rows above it. What it plays records into clips (`Take`). + +const std = @import("std"); +const c = @import("../c.zig"); + +pub const Layout = enum(u8) { + piano, + grid, + chord, + + pub fn label(l: Layout) []const u8 { + return LAYOUT_LABELS[@intFromEnum(l)]; + } +}; +pub const LAYOUT_LABELS = [_][]const u8{ "PIANO", "GRID", "CHORD" }; + +/// Chord shapes in scale degrees above the chord's root, so every chord +/// stays in key. +pub const ChordType = enum(u8) { + triad, + seventh, + sus2, + sus4, + ninth, + power, + + pub fn label(t: ChordType) []const u8 { + return CHORD_LABELS[@intFromEnum(t)]; + } + + fn degrees(t: ChordType) []const i32 { + return switch (t) { + .triad => &.{ 0, 2, 4 }, + .seventh => &.{ 0, 2, 4, 6 }, + .sus2 => &.{ 0, 1, 4 }, + .sus4 => &.{ 0, 3, 4 }, + .ninth => &.{ 0, 2, 4, 6, 8 }, + .power => &.{ 0, 4, 7 }, + }; + } +}; +pub const CHORD_LABELS = [_][]const u8{ "TRIAD", "7TH", "SUS2", "SUS4", "9TH", "POWER" }; + +/// The piano roll's key: `mask` bit i = i semitones above `root`. +pub const Scale = struct { + root: u8 = 0, + mask: u12 = 0xfff, + on: bool = false, +}; +const MAJOR: u12 = 0b101010110101; + +pub var enabled = false; +pub var layout: Layout = .piano; +/// The base C is C`octave` (60 = C4, the piano roll's naming). +pub var octave: i8 = 4; +pub var velocity: u8 = 100; +pub var chord_type: ChordType = .triad; +/// REC with KEYS on: write what's played into a clip while the +/// transport runs (main drives the `take`). +pub var recording = false; +pub var take: Take = .{}; + +pub const OCTAVE_MIN: i8 = -1; +pub const OCTAVE_MAX: i8 = 8; +const VELOCITIES = [_]u8{ 1, 20, 40, 60, 80, 100, 127 }; + +// Rows bottom to top, as a Push's pads. +const ROWS = [4][10]c_int{ + .{ c.rl.KEY_Z, c.rl.KEY_X, c.rl.KEY_C, c.rl.KEY_V, c.rl.KEY_B, c.rl.KEY_N, c.rl.KEY_M, c.rl.KEY_COMMA, c.rl.KEY_PERIOD, c.rl.KEY_SLASH }, + .{ c.rl.KEY_A, c.rl.KEY_S, c.rl.KEY_D, c.rl.KEY_F, c.rl.KEY_G, c.rl.KEY_H, c.rl.KEY_J, c.rl.KEY_K, c.rl.KEY_L, c.rl.KEY_SEMICOLON }, + .{ c.rl.KEY_Q, c.rl.KEY_W, c.rl.KEY_E, c.rl.KEY_R, c.rl.KEY_T, c.rl.KEY_Y, c.rl.KEY_U, c.rl.KEY_I, c.rl.KEY_O, c.rl.KEY_P }, + .{ c.rl.KEY_ONE, c.rl.KEY_TWO, c.rl.KEY_THREE, c.rl.KEY_FOUR, c.rl.KEY_FIVE, c.rl.KEY_SIX, c.rl.KEY_SEVEN, c.rl.KEY_EIGHT, c.rl.KEY_NINE, c.rl.KEY_ZERO }, +}; +/// PIANO: semitones above the base C. +const PIANO = [_]c_int{ + c.rl.KEY_A, c.rl.KEY_W, c.rl.KEY_S, c.rl.KEY_E, c.rl.KEY_D, c.rl.KEY_F, c.rl.KEY_T, c.rl.KEY_G, c.rl.KEY_Y, + c.rl.KEY_H, c.rl.KEY_U, c.rl.KEY_J, c.rl.KEY_K, c.rl.KEY_O, c.rl.KEY_L, c.rl.KEY_P, c.rl.KEY_SEMICOLON, c.rl.KEY_APOSTROPHE, +}; +/// Octave and velocity: - = [ ] in every layout, and Z X C V on PIANO. +const CONTROLS = [_]c_int{ c.rl.KEY_MINUS, c.rl.KEY_EQUAL, c.rl.KEY_LEFT_BRACKET, c.rl.KEY_RIGHT_BRACKET }; +const PIANO_CONTROLS = [_]c_int{ c.rl.KEY_Z, c.rl.KEY_X, c.rl.KEY_C, c.rl.KEY_V }; + +fn baseC(oct: i8) i32 { + return 12 * (@as(i32, oct) + 1); +} + +fn bit(p: i32) u128 { + if (p < 0 or p > 127) return 0; + return @as(u128, 1) << @intCast(p); +} + +/// The pitch `degree` steps up `mask` from `root` (negative steps down). +pub fn degreePitch(root: i32, mask: u12, degree: i32) i32 { + const n: i32 = @popCount(mask); + if (n == 0) return root; + var idx = @mod(degree, n); + var semi: i32 = 0; + for (0..12) |b| { + if ((mask >> @intCast(b)) & 1 == 0) continue; + if (idx == 0) { + semi = @intCast(b); + break; + } + idx -= 1; + } + return root + 12 * @divFloor(degree, n) + semi; +} + +fn rowCol(key: c_int) ?[2]i32 { + for (ROWS, 0..) |row, r| for (row, 0..) |k, col| { + if (k == key) return .{ @intCast(r), @intCast(col) }; + }; + return null; +} + +/// The pitches `key` plays (0 when it plays none). +pub fn pitchesFor(lay: Layout, key: c_int, oct: i8, scale: Scale, ct: ChordType) u128 { + const base = baseC(oct); + switch (lay) { + .piano => { + for (PIANO, 0..) |k, i| if (k == key) return bit(base + @as(i32, @intCast(i))); + return 0; + }, + .grid => { + const rc = rowCol(key) orelse return 0; + // Up a row is a fourth: five semitones, or three steps in key. + if (scale.on) return bit(degreePitch(base + scale.root, scale.mask, rc[1] + 3 * rc[0])); + return bit(base + rc[1] + 5 * rc[0]); + }, + .chord => { + const rc = rowCol(key) orelse return 0; + const mask = if (scale.on) scale.mask else MAJOR; + const root = base + scale.root; + const n: i32 = @popCount(mask); + return switch (rc[0]) { + // Chords an octave under the melody, one per degree. + 0 => blk: { + var set: u128 = 0; + for (ct.degrees()) |d| set |= bit(degreePitch(root - 12, mask, rc[1] + d)); + break :blk set; + }, + 1 => bit(degreePitch(root, mask, rc[1])), + 2 => bit(degreePitch(root, mask, rc[1] + n)), + else => 0, // the number row picks the chord type + }; + }, + } +} + +fn inList(list: []const c_int, key: c_int) bool { + for (list) |k| if (k == key) return true; + return false; +} + +/// Whether `key` belongs to musical typing in `lay` (so the panes' +/// unmodified shortcuts on it stand down). +pub fn layoutClaims(lay: Layout, key: c_int) bool { + if (inList(&CONTROLS, key)) return true; + return switch (lay) { + .piano => inList(&PIANO, key) or inList(&PIANO_CONTROLS, key), + .grid, .chord => rowCol(key) != null, + }; +} + +// ── Live state ─────────────────────────────────────────────────────── + +const MAX_HELD = 48; +const Held = struct { key: c_int, pitches: u128 }; +var held: [MAX_HELD]Held = undefined; +var held_n: usize = 0; +/// This frame a text field, menu or dialog owns the keys, or ⌘ is down. +var standing_down = true; + +/// Whether musical typing takes `key` this frame. +pub fn claims(key: c_int) bool { + return enabled and !standing_down and layoutClaims(layout, key); +} + +/// The pitches the keys hold now. +pub fn sounding() u128 { + var set: u128 = 0; + for (held[0..held_n]) |h| set |= h.pitches; + return set; +} + +pub fn setEnabled(on: bool) void { + enabled = on; + if (!on) { + held_n = 0; + recording = false; + } +} + +pub fn octaveName(buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, "C{d}", .{octave}) catch "C"; +} + +fn stepVelocity(dir: i32) void { + var i: usize = 0; + while (i < VELOCITIES.len and VELOCITIES[i] < velocity) i += 1; + if (dir < 0) { + velocity = if (i == 0) VELOCITIES[0] else VELOCITIES[i - 1]; + } else { + const at = if (i < VELOCITIES.len and VELOCITIES[i] == velocity) i + 1 else i; + velocity = VELOCITIES[@min(at, VELOCITIES.len - 1)]; + } +} + +pub fn stepOctave(dir: i32) void { + octave = @intCast(std.math.clamp(@as(i32, octave) + dir, OCTAVE_MIN, OCTAVE_MAX)); +} + +/// Once a frame: let go of released keys, then (when free) take octave, +/// velocity, chord-type and note presses. `free` is false while a text +/// field, menu or dialog owns the keyboard or ⌘ is down. +pub fn poll(free: bool, scale: Scale) void { + standing_down = !free; + var i: usize = 0; + while (i < held_n) { + if (!c.rl.IsKeyDown(held[i].key)) { + held[i] = held[held_n - 1]; + held_n -= 1; + } else i += 1; + } + if (!enabled or !free) return; + const pressed = c.rl.IsKeyPressed; + const piano = layout == .piano; + if (pressed(c.rl.KEY_MINUS) or (piano and pressed(c.rl.KEY_Z))) stepOctave(-1); + if (pressed(c.rl.KEY_EQUAL) or (piano and pressed(c.rl.KEY_X))) stepOctave(1); + if (pressed(c.rl.KEY_LEFT_BRACKET) or (piano and pressed(c.rl.KEY_C))) stepVelocity(-1); + if (pressed(c.rl.KEY_RIGHT_BRACKET) or (piano and pressed(c.rl.KEY_V))) stepVelocity(1); + if (layout == .chord) for (ROWS[3][0..CHORD_LABELS.len], 0..) |k, t| { + if (pressed(k)) chord_type = @enumFromInt(t); + }; + for (ROWS) |row| for (row) |k| tryPress(k, scale); + tryPress(c.rl.KEY_APOSTROPHE, scale); +} + +fn tryPress(key: c_int, scale: Scale) void { + if (!c.rl.IsKeyPressed(key)) return; + for (held[0..held_n]) |h| if (h.key == key) return; + const p = pitchesFor(layout, key, octave, scale, chord_type); + if (p == 0 or held_n >= MAX_HELD) return; + held[held_n] = .{ .key = key, .pitches = p }; + held_n += 1; +} + +// ── Recording ──────────────────────────────────────────────────────── + +/// A played note, in song beats. +pub const RecNote = struct { pitch: u8, start: f64, end: f64, velocity: u8 }; + +/// One recording pass: notes open on key presses and close on releases, +/// in song beats. A loop wrap closes what's held at the loop's end and +/// opens it again at its start. +pub const Take = struct { + active: bool = false, + /// The clip the pass writes (main's ClipRef: track, clip). + target: ?[2]u32 = null, + prev: u128 = 0, + open: u128 = 0, + open_start: [128]f64 = undefined, + open_vel: [128]u8 = undefined, + last_beat: f64 = 0, + + /// Keys already down when the pass starts aren't recorded. + pub fn begin(self: *Take, held_now: u128, beat: f64) void { + self.* = .{ .active = true, .prev = held_now, .last_beat = beat }; + } + + /// The notes that closed this frame go to `out` (room for 256). + pub fn update(self: *Take, held_now: u128, vel: u8, beat: f64, out: []RecNote) usize { + var n: usize = 0; + if (beat + 1e-6 < self.last_beat) { + const again = self.open & held_now; + n += self.closeSet(self.open, self.last_beat, out[n..]); + self.openSet(again, vel, beat); + } + n += self.closeSet(self.open & self.prev & ~held_now, beat, out[n..]); + self.openSet(held_now & ~self.prev, vel, beat); + self.prev = held_now; + self.last_beat = beat; + return n; + } + + /// The pass ends: whatever's open closes where it stopped. + pub fn finish(self: *Take, out: []RecNote) usize { + const n = self.closeSet(self.open, self.last_beat, out); + self.* = .{}; + return n; + } + + fn openSet(self: *Take, set: u128, vel: u8, beat: f64) void { + if (set == 0) return; + for (0..128) |p| if (set & (@as(u128, 1) << @intCast(p)) != 0) { + self.open_start[p] = beat; + self.open_vel[p] = vel; + }; + self.open |= set; + } + + fn closeSet(self: *Take, set: u128, beat: f64, out: []RecNote) usize { + if (set == 0) return 0; + var n: usize = 0; + for (0..128) |p| if (set & (@as(u128, 1) << @intCast(p)) != 0 and n < out.len) { + out[n] = .{ .pitch = @intCast(p), .start = self.open_start[p], .end = beat, .velocity = self.open_vel[p] }; + n += 1; + }; + self.open &= ~set; + return n; + } +}; + +// ── Tests ──────────────────────────────────────────────────────────── + +const testing = std.testing; + +fn only(set: u128) i32 { + return @intCast(@ctz(set)); +} + +test "piano row is chromatic from the base C" { + try testing.expectEqual(@as(i32, 60), only(pitchesFor(.piano, c.rl.KEY_A, 4, .{}, .triad))); + try testing.expectEqual(@as(i32, 61), only(pitchesFor(.piano, c.rl.KEY_W, 4, .{}, .triad))); + try testing.expectEqual(@as(i32, 72), only(pitchesFor(.piano, c.rl.KEY_K, 4, .{}, .triad))); + try testing.expectEqual(@as(i32, 48), only(pitchesFor(.piano, c.rl.KEY_A, 3, .{}, .triad))); + try testing.expectEqual(@as(u128, 0), pitchesFor(.piano, c.rl.KEY_Q, 4, .{}, .triad)); +} + +test "grid rows are fourths, in key with a scale" { + // Chromatic: up a row is five semitones. + try testing.expectEqual(@as(i32, 60), only(pitchesFor(.grid, c.rl.KEY_Z, 4, .{}, .triad))); + try testing.expectEqual(@as(i32, 65), only(pitchesFor(.grid, c.rl.KEY_A, 4, .{}, .triad))); + try testing.expectEqual(@as(i32, 61), only(pitchesFor(.grid, c.rl.KEY_X, 4, .{}, .triad))); + // D major: Z = D, X = E, A = G (three steps up), 1 = F#5 (nine up). + const d_major = Scale{ .root = 2, .mask = MAJOR, .on = true }; + try testing.expectEqual(@as(i32, 62), only(pitchesFor(.grid, c.rl.KEY_Z, 4, d_major, .triad))); + try testing.expectEqual(@as(i32, 64), only(pitchesFor(.grid, c.rl.KEY_X, 4, d_major, .triad))); + try testing.expectEqual(@as(i32, 67), only(pitchesFor(.grid, c.rl.KEY_A, 4, d_major, .triad))); + try testing.expectEqual(@as(i32, 78), only(pitchesFor(.grid, c.rl.KEY_ONE, 4, d_major, .triad))); +} + +test "chord row plays diatonic chords under the melody" { + const c_major = Scale{ .root = 0, .mask = MAJOR, .on = true }; + // Z = C major an octave down, X = D minor, B (V) = G7 with 7TH. + try testing.expectEqual(bit(48) | bit(52) | bit(55), pitchesFor(.chord, c.rl.KEY_Z, 4, c_major, .triad)); + try testing.expectEqual(bit(50) | bit(53) | bit(57), pitchesFor(.chord, c.rl.KEY_X, 4, c_major, .triad)); + try testing.expectEqual(bit(55) | bit(59) | bit(62) | bit(65), pitchesFor(.chord, c.rl.KEY_B, 4, c_major, .seventh)); + // A minor: Z = A minor triad. + const a_minor = Scale{ .root = 9, .mask = 0b010110101101, .on = true }; + try testing.expectEqual(bit(57) | bit(60) | bit(64), pitchesFor(.chord, c.rl.KEY_Z, 4, a_minor, .triad)); + // The melody rows: A = root, Q = an octave up. + try testing.expectEqual(@as(i32, 60), only(pitchesFor(.chord, c.rl.KEY_A, 4, c_major, .triad))); + try testing.expectEqual(@as(i32, 62), only(pitchesFor(.chord, c.rl.KEY_S, 4, c_major, .triad))); + try testing.expectEqual(@as(i32, 72), only(pitchesFor(.chord, c.rl.KEY_Q, 4, c_major, .triad))); + // Scale off: major on the root. + try testing.expectEqual(bit(48) | bit(52) | bit(55), pitchesFor(.chord, c.rl.KEY_Z, 4, .{}, .triad)); + try testing.expectEqual(@as(u128, 0), pitchesFor(.chord, c.rl.KEY_ONE, 4, c_major, .triad)); +} + +test "degrees wrap across octaves both ways" { + try testing.expectEqual(@as(i32, 72), degreePitch(60, MAJOR, 7)); + try testing.expectEqual(@as(i32, 59), degreePitch(60, MAJOR, -1)); + try testing.expectEqual(@as(i32, 48), degreePitch(60, MAJOR, -7)); +} + +test "layouts claim their keys and the controls" { + try testing.expect(layoutClaims(.piano, c.rl.KEY_S)); + try testing.expect(layoutClaims(.piano, c.rl.KEY_Z)); + try testing.expect(!layoutClaims(.piano, c.rl.KEY_Q)); + try testing.expect(layoutClaims(.grid, c.rl.KEY_Q)); + try testing.expect(layoutClaims(.chord, c.rl.KEY_ZERO)); + try testing.expect(layoutClaims(.grid, c.rl.KEY_LEFT_BRACKET)); + try testing.expect(!layoutClaims(.grid, c.rl.KEY_SPACE)); + try testing.expect(!layoutClaims(.grid, c.rl.KEY_TAB)); +} + +test "velocity steps through the fixed ladder" { + const saved = velocity; + defer velocity = saved; + velocity = 100; + stepVelocity(1); + try testing.expectEqual(@as(u8, 127), velocity); + stepVelocity(1); + try testing.expectEqual(@as(u8, 127), velocity); + stepVelocity(-1); + try testing.expectEqual(@as(u8, 100), velocity); + velocity = 90; + stepVelocity(-1); + try testing.expectEqual(@as(u8, 80), velocity); +} + +test "a take records presses and releases, and splits at a loop wrap" { + var t: Take = .{}; + var out: [256]RecNote = undefined; + t.begin(bit(50), 0); // held before the pass: not recorded + try testing.expectEqual(@as(usize, 0), t.update(bit(50) | bit(60), 90, 1, &out)); + try testing.expectEqual(@as(usize, 0), t.update(bit(60) | bit(64), 90, 2, &out)); + var n = t.update(bit(64), 90, 3, &out); + try testing.expectEqual(@as(usize, 1), n); + try testing.expectEqual(RecNote{ .pitch = 60, .start = 1, .end = 3, .velocity = 90 }, out[0]); + // The loop wraps from 3.9 to 0.1: 64 closes at 3.9 and opens again. + _ = t.update(bit(64), 90, 3.9, &out); + n = t.update(bit(64), 90, 0.1, &out); + try testing.expectEqual(@as(usize, 1), n); + try testing.expectEqual(RecNote{ .pitch = 64, .start = 2, .end = 3.9, .velocity = 90 }, out[0]); + n = t.finish(&out); + try testing.expectEqual(@as(usize, 1), n); + try testing.expectEqual(RecNote{ .pitch = 64, .start = 0.1, .end = 0.1, .velocity = 90 }, out[0]); + try testing.expect(!t.active); +} diff --git a/src/ui/transport_bar.zig b/src/ui/transport_bar.zig index ec372938..9ac9d45f 100644 --- a/src/ui/transport_bar.zig +++ b/src/ui/transport_bar.zig @@ -1,7 +1,7 @@ //! Workbench transport bar on the new Ui (docs/06 §Packing: a toolbar of //! flush tiles). Replaces the legacy top_bar.zig. //! -//! [file ▾][■/▶][●][▾][⟲][-][● 124.0][+][TAP][ 1.1.1][ 4/4][-][1/16][+][ ][SLAB] +//! [file ▾][■][▶][●][▾][⟲][-][● 124.0][+][TAP][ 1.1.1][ 4/4][-][1/16][+][ ][SLAB] //! //! Menus (file, input device, meter denominator) and tooltips still go //! through the legacy widgets until menus move onto the new core. @@ -18,6 +18,7 @@ const snap_mod = @import("snap.zig"); const Transport = @import("../transport.zig").Transport; const audio_mod = @import("../audio.zig"); const meter_mod = @import("../meter.zig"); +const musical_keys = @import("musical_keys.zig"); const Ui = core.Ui; const Rect = core.Rect; @@ -111,10 +112,18 @@ pub fn draw(ui: *Ui, r: Rect, a: Args) Result { menu.tip(ui, browse_r, "Browser (\u{2318}\u{2325}B)"); // Transport. + // STOP stops; pressed again while stopped it returns to the loop's + // start, or the song's with the loop off. var playing = t.isPlaying(); + const stop_r = bar.cutLeft(36); + var stop_on = false; + if (ctl.button(ui, stop_r, "stop", &stop_on, .{ .glyph = .square6, .glyph_on = style.text, .flush = true })) { + if (t.isPlaying()) t.stop() else if (t.loopEnabled()) t.seekToBeats(t.loopStartBeats()) else t.rewind(); + } + menu.tip(ui, stop_r, if (playing) "Stop Space" else if (t.loopEnabled()) "Stop again: to the loop start" else "Stop again: to the start"); const play_r = bar.cutLeft(36); - if (ctl.button(ui, play_r, "play", &playing, .{ .glyph = if (playing) .square6 else .tri_right, .glyph_on = style.play, .flush = true })) t.toggle(); - menu.tip(ui, play_r, if (playing) "Stop Space" else "Play Space"); + if (ctl.button(ui, play_r, "play", &playing, .{ .glyph = .tri_right, .glyph_on = style.play, .flush = true })) t.play(); + menu.tip(ui, play_r, "Play Space"); var rec_on = a.recording; const rec_r = bar.cutLeft(36); if (ctl.button(ui, rec_r, "rec", &rec_on, .{ .glyph = .round7, .glyph_on = style.rec, .flush = true, .disabled = !a.can_record })) res.record_toggle = true; @@ -154,6 +163,8 @@ pub fn draw(ui: *Ui, r: Rect, a: Args) Result { if (ctl.displaySelectEx(ui, snap_r, "snap", &snap, &SNAP_LABELS, "SNAP", .{ .large = true, .flush = true })) a.edit_snap.* = @enumFromInt(snap); menu.tip(ui, snap_r, std.mem.span(a.edit_snap.tooltip())); + keysTile(ui, &bar); + // Logo plate on the right, the master meter beside it, blank plate // between. if (logoTile(ui, bar.cutRight(logoW(bar.h)))) res.about = true; @@ -174,6 +185,59 @@ const SNAP_LABELS = blk: { // ── Tiles ──────────────────────────────────────────────────────────── +/// Musical typing (docs/40): KEYS on/off, the layout, the base octave, the +/// velocity, and CHORD's chord type. +fn keysTile(ui: *Ui, bar: *Rect) void { + ui.pushId("keys"); + defer ui.popId(); + const on_r = bar.cutLeft(52); + var on = musical_keys.enabled; + if (ctl.button(ui, on_r, "on", &on, .{ .kind = .latch, .label = "KEYS", .led = style.accent, .flush = true })) musical_keys.setEnabled(on); + menu.tip(ui, on_r, "Musical typing: the computer keyboard plays the selected track; REC records it"); + const dim = !musical_keys.enabled; + + const lay_r = bar.cutLeft(76); + var lay: u8 = @intFromEnum(musical_keys.layout); + if (ctl.displaySelectEx(ui, lay_r, "layout", &lay, &musical_keys.LAYOUT_LABELS, "KEYS", .{ .flush = true, .dim = dim })) musical_keys.layout = @enumFromInt(lay); + menu.tip(ui, lay_r, switch (musical_keys.layout) { + .piano => "PIANO: A W S E D F T G Y H U J K = C to C; Z X octave, C V velocity", + .grid => "GRID: four rows in fourths (in key with a scale); - = octave, [ ] velocity", + .chord => "CHORD: Z row chords, A and Q rows melody, 1-6 chord type; - = octave, [ ] velocity", + }); + + const oct_r = bar.cutLeft(52); + var obuf: [8]u8 = undefined; + // A spare cell on the right for the steps. + var sbuf: [10]u8 = undefined; + const oct_s = std.fmt.bufPrint(&sbuf, "{s} ", .{musical_keys.octaveName(&obuf)}) catch ""; + ctl.display(ui, oct_r, oct_s, .{ .align_ = .right, .flush = true, .color = if (dim) style.text_mute else style.vfd }); + const step = ctl.glassSteps(ui, oct_r, "oct-step"); + if (step != 0) musical_keys.stepOctave(step); + menu.tip(ui, oct_r, "Octave of the keys' lowest C"); + + const vel_r = bar.cutLeft(44); + const wid = ui.id("vel"); + const b = ui.behavior(wid, vel_r, false); + if (b.double) { + musical_keys.velocity = 100; + } else if (b.held) { + const v = @as(f32, @floatFromInt(musical_keys.velocity)) - ui.in.dy * ui.renderer.zoom * 0.5; + musical_keys.velocity = @intFromFloat(std.math.clamp(@round(v), 1, 127)); + } + if (ui.isHot(wid)) ui.requestCursor(c.rl.MOUSE_CURSOR_RESIZE_NS, 1); + var vbuf: [8]u8 = undefined; + const vel_s = std.fmt.bufPrint(&vbuf, "V{d}", .{musical_keys.velocity}) catch ""; + ctl.display(ui, vel_r, vel_s, .{ .align_ = .right, .flush = true, .color = if (dim) style.text_mute else if (ui.active == wid) style.vfd_hi else style.vfd }); + menu.tip(ui, vel_r, "Velocity of the keys' notes: drag, double-click 100"); + + if (musical_keys.layout == .chord) { + const ct_r = bar.cutLeft(64); + var ct: u8 = @intFromEnum(musical_keys.chord_type); + if (ctl.displaySelectEx(ui, ct_r, "chord", &ct, &musical_keys.CHORD_LABELS, "CHORD", .{ .flush = true, .dim = dim })) musical_keys.chord_type = @enumFromInt(ct); + menu.tip(ui, ct_r, "Chord the Z row plays, in key (keys 1-6)"); + } +} + const MASTER_W: i32 = 400; const MASTER_MIN_W: i32 = 220; const VOL_W: i32 = 96;