Skip to content
This repository has been archived by the owner on Jan 6, 2021. It is now read-only.

[WIP] Midi import #30

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions build.boot
Expand Up @@ -64,6 +64,7 @@
alda.lisp.variables-test
alda.lisp.voices-test
alda.util-test
alda.import.midi-test

; benchmarks / smoke tests
alda.examples-test}})
Expand Down
Binary file added examples/midi/pokemon.mid
Binary file not shown.
Binary file added examples/midi/twotone.mid
Binary file not shown.
19 changes: 19 additions & 0 deletions src/alda/import.clj
@@ -0,0 +1,19 @@
(ns alda.import
"alda.import transforms other music files into Alda scores, which can then be stored
or played. Currently we support importing MIDI files."
(:require [potemkin.namespaces :refer (import-vars)]
[alda.util :as util]))

; sets log level to TIMBRE_LEVEL (if set) or :warn
(util/set-log-level!)

(defn- import-all-vars
"Imports all public vars from a namespace into the alda.import namespace."
[ns]
(eval (list `import-vars (cons ns (keys (ns-publics ns))))))

(def ^:private namespaces
'[alda.import.midi])

(doseq [ns namespaces]
(require ns))
70 changes: 70 additions & 0 deletions src/alda/import/midi.clj
@@ -0,0 +1,70 @@
(ns alda.import.midi
(:import [javax.sound.midi MidiSystem]
[java.io File]))

(comment
"This module accepts raw bytecode from a midi file, and converts it to alda syntax
which is then printed to STDOUT.")

(defn- find-existing
"Given some criteria, find the first matching hash in a collection of hashes"
[collection criteria]
(first (filter #(= criteria (select-keys % (keys criteria))) collection)))

(defn- note-on
"We've just heard a new note beginning, update state partials to include it"
[{:keys [instrument] :as state} event]
(update state :partials conj {
:channel (-> event .getMessage .getChannel)
:pitch (-> event .getMessage .getData1)
:volume (-> event .getMessage .getData2)
:start (-> event .getTick)
:instrument instrument
; duration will be set when the note completes
:duration nil}))

(defn- note-off
"We've just heard a note complete; remove the note from partials, add a duration, and include it as a completed note"
[{:keys [partials] :as state} event]
(if-let [{:keys [start] :as partial-note}
(find-existing partials
{:pitch (-> event .getMessage .getData1)
:channel (-> event .getMessage .getChannel)
:duration nil})]
; (or (println "note: " partial-note) (println "end: " (.getTick event)) (println "start: " start) (println "dur: " (- (.getTick event) start)))
(let [completed-note (assoc partial-note :duration (- (.getTick event) start))]
(-> state
(update :partials (partial remove #{partial-note}))
(update :notes conj completed-note)))
state))

(defn- program-change
"We've just switched instruments in this channel; update state to reflect this"
[state event]
(assoc state :instrument (-> event .getMessage .getData1)))

(defn- is-defined?
"See if a java class responds to a method"
[message method]
(some #(= (.getName %) method) (-> message .getClass .getMethods)))

(defn process-note-event
[state event]
(let [message (.getMessage event)]
(case (if (is-defined? message "getCommand") (.getCommand message))
; a note_on event also signifies that the previous note should be turned off
144 (-> state (note-off event) (note-on event))
128 (note-off state event)
192 (program-change state event)
state))) ; this isn't an event we care about; return the original state

(defn- notes-from-track
"Get a final state by reading through a series of note events"
[track]
(let [events (map #(.get track %) (range (.size track)))]
(get (reduce process-note-event {} events) :notes ())))

(defn import-midi
"Imports a .mid or .midi file specified by path, and prints it to STDOUT"
[path]
(mapcat notes-from-track (-> (File. path) MidiSystem/getSequence .getTracks)))
67 changes: 67 additions & 0 deletions test/alda/import/midi_test.clj
@@ -0,0 +1,67 @@
(ns alda.import.midi-test
(:require [clojure.test :refer :all]
[alda.test-helpers :refer (get-instrument)]
[alda.import.midi :refer :all])
(:import [javax.sound.midi MidiEvent MidiMessage ShortMessage]
[com.sun.media.sound FastShortMessage]))

(defn- build-event
"Build an event for testing"
([command] (build-event command 0))
([command channel] (build-event command channel 0))
([command channel data1] (build-event command channel data1 0))
([command channel data1 data2] (build-event command channel data1 data2 10))
([command channel data1 data2 seq]
(new MidiEvent (new ShortMessage command channel data1 data2) seq)))

(deftest midi-tests
(testing "can process a short midi file"
(let [result (import-midi "./examples/midi/twotone.mid")]
; we get a collection with two tones
(is (= 2 (count result)))

; first note
(is (= 0 (get (first result) :instrument)))
(is (= 0 (get (first result) :start)))
(is (= 0 (get (first result) :channel)))
(is (= 72 (get (first result) :pitch)))
(is (= 11520 (get (first result) :duration)))
(is (= 40 (get (first result) :volume)))
; second note
(is (= 0 (get (last result) :instrument)))
(is (= 1440 (get (last result) :start)))
(is (= 0 (get (last result) :channel)))
(is (= 74 (get (last result) :pitch)))
(is (= 7200 (get (last result) :duration)))
(is (= 40 (get (last result) :volume)))
)
)

; this doesn't work yet, I think because this file has 0-duration NOTE_ON events in lieu of NOTE_OFF events
; (apparantly that's a thing that MIDI does sometimes)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've noticed that in MIDI, playing the same note on the same note consecutively will cause a sort of implicit "NOTE OFF" for the first note so that the second note can play. Maybe we can adjust the logic for when to add a note to account for this?

; (testing "can process a big midi file"
; (is (= "some test data" (import-midi "./examples/midi/pokemon.mid")))
; )

(testing "note-on adds a new note partial"
(let [event (build-event 144 9)
state (process-note-event {} event)]
(is (= 1 (count (get state :partials))))
(is (= 9 (get (first (get state :partials)) :channel)))))

(testing "note-off updates an existing note with the duration"
(let [event (build-event 128 0 0 0 50)
prev-state (process-note-event {} (build-event 144 0 0 0 10))
state (process-note-event prev-state event)]
(is (= 1 (count (get state :notes))))
(is (= 40 (get (first (get state :notes)) :duration)))))

(testing "change-program updates the instrument of the state"
(let [event (build-event 192 0 15) ; include '15' as the data for the instrument to change to
state (process-note-event {} event)]
(is (= 15 (get state :instrument)))))

(testing "other events don't change the state"
(let [event (build-event 208)
state (process-note-event {} event)]
(is (= {} state)))))