Skip to content

Conversation

@Rahzael
Copy link

@Rahzael Rahzael commented Jan 17, 2026

A work in progress attempt to implement appendix D of the Midi 2.0 spec that allows for automatic conversion between specific MIDI 2.0 and 1.0 messages.

See #16 for details.

@Rahzael Rahzael mentioned this pull request Jan 17, 2026
}

#[cfg(feature = "channel-voice1")]
impl<const N: usize> Into<NoteOn1<[u32; N]>> for NoteOn<[u32; N]> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could this implementation be generic over the buffer type ? I guess that might be a little compilicated since we need the buffer type of both the current message and the converted to message. You could peek at the implementation for the other conversion traits (in generate_message.rs).

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, it was a little complicated to get it working over a generic buffer. I had issues satisfying the required trait bounds, and even when I did, it wouldn't work with fixed size buffers. I'll take a look at generate_message and try experimenting with it some more, though.

Copy link
Author

Choose a reason for hiding this comment

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

Ah, I see that generate_message is a proc macro. I know a little bit of the theory behind them but have never actually worked with them before. It may take me a bit to figure this out.

Copy link
Author

Choose a reason for hiding this comment

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

Ah, I think I see now, most of them seem to be operating on a slice of u32, instead of an owned buffer. Let me see if I can go that route for this. I think that approach will only work for the CV2 -> CV1 conversion, though. CV1 -> CV2 we won't have enough room to do a conversion in-place.

Copy link
Author

Choose a reason for hiding this comment

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

I might have to try again tomorrow on this. Perhaps you can clarify what being generic over a buffer should actually mean in this case?

Are we trying to do an in-place replacement on the existing buffer, or are we returning a new buffer with the new NoteOn message in it?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah this case is slightly more tricky because we will be converting from one message generic over it's buffer into another message, ideally also generic over its buffer.

Are we trying to do an in-place replacement on the existing buffer, or are we returning a new buffer with the new NoteOn message in it?

Creating a new message wouldbe the most consistent thing. The generic RebufferInto trait does something similar - creates a new message (with arbitrary generic buffer) from an existing generic message.

Because you'll need to also create the buffer, you'll need to include the BufferDefault trait requirement on the target buffer type.

Copy link
Author

Choose a reason for hiding this comment

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

Creating a new message wouldbe the most consistent thing. The generic RebufferInto trait does something similar - creates a new message (with arbitrary generic buffer) from an existing generic message.

Because you'll need to also create the buffer, you'll need to include the BufferDefault trait requirement on the target buffer type.

If I'm reading the rebuffer from macro correctly, it looks like there are actually three different implementations?

fn rebuffer_from_impl(root_ident: &syn::Ident, args: &GenerateMessageArgs) -> TokenStream {
    let generics = match args.representation() {
        Representation::Ump => quote! {
            <
                A: crate::buffer::Ump,
                B: crate::buffer::Ump + crate::buffer::FromBuffer<A>,
            >
        },
        Representation::Bytes => quote! {
            <
                A: crate::buffer::Bytes,
                B: crate::buffer::Bytes + crate::buffer::FromBuffer<A>,
            >
        },
        Representation::UmpOrBytes => quote! {
            <
                U: crate::buffer::Unit,
                A: crate::buffer::Buffer<Unit = U>,
                B: crate::buffer::Buffer<Unit = U> + crate::buffer::FromBuffer<A>,
            >
        },
    };
    quote! {
        impl #generics crate::traits::RebufferFrom<#root_ident<A>> for #root_ident<B>
        {
            fn rebuffer_from(other: #root_ident<A>) -> Self {
                #root_ident(<B as crate::buffer::FromBuffer<A>>::from_buffer(other.0))
            }
        }
    }
}

You have the UMP to UMP case, the Bytes to Bytes case, and the generic buffer case.

Copy link
Author

@Rahzael Rahzael Jan 18, 2026

Choose a reason for hiding this comment

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

Actually, the more I consider this problem, the more I think we should be allowing the user to provide us with a buffer to store the resulting CV1 message into.

For example, when we want to scale this up to being able to convert an entire CV2 stream into a CV1 stream, we don't want each conversion to make it's own buffer than have to concatenate each of those buffers into a full stream buffer. We'd likely want to make a new buffer for the entire stream, then have each conversion push it's value onto that new buffer that represents the new CV1 stream.

This means that our Into implementation might look a little something like this:

impl
<
    U: crate::buffer::Unit,
    A: crate::buffer::Buffer<Unit = U>,
    B: crate::buffer::Buffer<Unit = U> + crate::buffer::FromBuffer<A>,
>
Into<crate::channel_voice1::NoteOn<B> for (NoteOn<A>, &mut B) { }

Copy link
Author

Choose a reason for hiding this comment

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

Or, more generically, we might be able to pass in a mutable slice for it to store the message in:

impl
<
    U: crate::buffer::Unit,
    A: crate::buffer::Buffer<Unit = U>,
>
Into<crate::channel_voice1::NoteOn<&[u32]> for (NoteOn<A>, &mut [u32]) { }

Copy link
Author

@Rahzael Rahzael Jan 19, 2026

Choose a reason for hiding this comment

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

@BenLeadbetter I think I was finally able to figure this out, at least for generic buffers with Unit = u32. (This was required by the compiler, but if you have ideas to remove this restriction, let me know.)

I had to split this up into 2 different implementations. The first works for both fixed size and resizable buffers, and defers potentially fallible instantiation to the caller. It turns a (CV2, CV1) tuple into a CV1 so long as the CV1 buffer is mutable.

/// Converts a CV2 Note On message to CV1 Note On message,
/// storing the result in a pre-instantiated CV1 Note On.
///
/// Note: Due to 0 velocity Note On messages being considered
/// a Note Off in CV1 but not in CV2, a 0 velocity CV2 message
/// will be converted to a 1 velocity CV1 message.
#[cfg(feature = "channel-voice1")]
impl<
        A: crate::buffer::Buffer<Unit = u32>,
        B: crate::buffer::Buffer<Unit = u32> + crate::buffer::BufferMut,
    > Into<crate::channel_voice1::NoteOn<B>> for (NoteOn<A>, crate::channel_voice1::NoteOn<B>)
{
    fn into(self) -> crate::channel_voice1::NoteOn<B> {
        let (src, mut dest) = self;
        dest.set_group(src.group());
        dest.set_channel(src.channel());
        dest.set_note_number(src.note_number());
        match src.velocity() {
            // Since 0 velocity doesn't trigger a note off in CV2 like in CV1,
            // we need to convert 0 velocity in CV2 to 1 velocity in CV1.
            // See MIDI 2.0 spec 7.4.2: MIDI 2.0 Note On Message -> Velocity
            // for details.
            0 => dest.set_velocity(u7::new(0x01)),
            _ => dest.set_velocity(u7::new((src.velocity() >> 9) as u8)),
        }
        dest
    }
}

The second is just a wrapper for the first, in which it allows direct conversion from a CV2 so long as the backing buffer type for the CV1 is both resizable and has a default.

/// Converts a CV2 Note On message to a CV1 Note On message.
/// This is only infallible for resizable buffers.
/// For fixed size buffers, see the Into impl for (CV2, CV1).
///
/// Note: Due to 0 velocity Note On messages being considered
/// a Note Off in CV1 but not in CV2, a 0 velocity CV2 message
/// will be converted to a 1 velocity CV1 message.
#[cfg(feature = "channel-voice1")]
impl<
        A: crate::buffer::Buffer<Unit = u32>,
        B: crate::buffer::Buffer<Unit = u32>
            + crate::buffer::BufferMut
            + crate::buffer::BufferDefault
            + crate::buffer::BufferResize,
    > Into<crate::channel_voice1::NoteOn<B>> for NoteOn<A>
{
    fn into(self) -> crate::channel_voice1::NoteOn<B> {
        let mut dest = crate::channel_voice1::NoteOn::<B>::new();
        (self, dest).into()
    }
}

Let me know if this seems like an acceptable approach, or if you have any ideas on how to improve it.

message.set_channel(self.channel());
message.set_note_number(self.note_number());
match self.velocity() {
0 => message.set_velocity(u7::new(0x01)),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should 0 on the cv2 message really translate to 1 on the cv1 ? I didn't spot that in the docs.

Copy link
Author

Choose a reason for hiding this comment

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

I remember reading that 0 velocity in Midi 2.0 should translate to 1 velocity in Midi 1.0 because 0 velocity note-on messages can't be ignored in Midi 2.0 but they can in 1.0. Let me see if I can pull up the specific reference.

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, it's under section 7.4.2: MIDI 2.0 Note On Message - Velocity:

The allowable Velocity range for a MIDI 2.0 Note On message is 0x0000-0xFFFF. Unlike the MIDI 1.0 Note
On message, a velocity value of zero does not function as a Note Off. When translating a MIDI 2.0 Note On
message to the MIDI 1.0 Protocol, if the translated MIDI 1.0 value of the Velocity is zero, then the Translator
shall replace the zero with a value of 1.

Copy link
Author

@Rahzael Rahzael Jan 18, 2026

Choose a reason for hiding this comment

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

A good change, in my opinion, as there are plenty of instruments that can (in theory) fade into and out of silence without triggering a new attack, such as wind instruments.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Good catch. Thanks for the clarification.

Perhaps this is worth a documentation comment? Or at least a code comment on the 0 branch of the match statement.

Copy link
Author

Choose a reason for hiding this comment

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

Would this suffice?

        match self.velocity() {
            // Since 0 velocity doesn't trigger a note off in CV2 like in CV1,
            // we need to convert 0 velocity in CV2 to 1 velocity in CV1.
            // See MIDI 2.0 spec 7.4.2: MIDI 2.0 Note On Message -> Velocity
            // for detials.
            0 => message.set_velocity(u7::new(0x01)),
            _ => message.set_velocity(u7::new((self.velocity() >> 9) as u8)),
        }

Copy link
Author

Choose a reason for hiding this comment

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

I also added a doc comment for the trait explaining this:

/// Converts a CV2 Note On message to a CV1 Note On message.
/// Note: Due to 0 velocity Note On messages being considered
/// a Note Off in CV1 but not in CV2, a 0 velocity CV2 message
/// will be converted to a 1 velocity CV1 message.
#[cfg(feature = "channel-voice1")]
impl<const N: usize> Into<channel_voice1::NoteOn<[u32; N]>> for NoteOn<[u32; N]> {
    fn into(self) -> channel_voice1::NoteOn<[u32; N]> {
        let mut message = channel_voice1::NoteOn::<[u32; N]>::new();
        message.set_group(self.group());
        message.set_channel(self.channel());
        message.set_note_number(self.note_number());
        match self.velocity() {
            // Since 0 velocity doesn't trigger a note off in CV2 like in CV1,
            // we need to convert 0 velocity in CV2 to 1 velocity in CV1.
            // See MIDI 2.0 spec 7.4.2: MIDI 2.0 Note On Message -> Velocity
            // for details.
            0 => message.set_velocity(u7::new(0x01)),
            _ => message.set_velocity(u7::new((self.velocity() >> 9) as u8)),
        }
        message
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants