kentwest.neocities.org

Rust - A Simple Morse Code Keyer App

Kent West - kent.west@{that mail that swore to do no evil}

  1. Assumptions

    1. You are running Debian GNU/Linux, about version 12.
    2. You have Rust installed.
    3. You know how to create/edit/run a simple Rust program such as "Hello, World!".
  2. Let's create "cw_keyer"

    1. $ cargo new cw_keyer
    2. Should result in a standard "Hello, World!" source file:

      src/main.rs
      fn main() {
          println!("Hello, world!");
      }
      
  3. Create a single tone for starters

  4. Rather than reinvent the wheel creating our own sound routines from scratch, let's use someone else's work. There are several possibilities, but a good choice for us is to use the rodio crate. If you want to read up on it, mosey on over to crates.io and search for "rodio".

    $ cargo add rodio should add the rodio crate to our project. You can verify that by looking at your Cargo.toml file before and after this command.

    Now that Cargo knows about rodio, we'll need to tell our Rust program what pieces of this new crate we'll be using, and we can get rid of the "Hello, World" line:

    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
     
    fn main() {
        println!("Hello, world!");
    }
    

    Creating the tone is a multi-part process.

    First, we have to create a "handle" on the default audio device, so we can "grab" hold of it and put sound into it, etc.

    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
    
    fn main() {
      // Create an output stream
      let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
    } // main()
    

    The OutputStream::try_default() method (or you might think of it as a function or a procedure or a "thing that the rodio crate knows how to do") returns two values: an "Output stream", and a "handle" for that Output stream. To be honest, I don't entirely know what is meant by "Output stream" in this context, but I do know we don't need it, so we tell Rust that even though we're collecting the value, we won't be using it in our program. We do this by placing an underscore at the front of our receiving variable, "_stream". We are assigning two variables at once in this line, one to recieve the returned stream (which as mentioned just now, we're ignoring), and the other to receive the handle for the stream, "stream_handle". (A single "package" that holds multiple variables like this is called a "tuple" (no relation to "two"; it could be three or thirty or three thousand variables).)

    We tell this command to try and grab the default sound device in the system (::try_default()), and that if we don't get what we expect (.expect("Could not open default audio device.");), to print out a message and to "crash" the program at that point. We could optionally just crash without the message (.unwrap();), or handle the error more gracefully, which often takes more coding than it's worth.

    Second, we need to generate a sine wave. You may know already that an audio tone is just a repeating sine wave. We have to define the sine wave's frequency and duration. 800 hertz is a fairly comfortable frequency, and let's just make it half a second long.

    A lot of things in Rust don't understand things like "3 seconds"; it wants this information defined in a special type of variable called a "Duration" type of variable. The "build a new sine wave" function of rodio is one of these things that wants its time-units in the "Duration" format. Although the definition of and manipulation of the "Duration" type is part of the Rust standard coding, it's not an "immediately standard" part; we'll have to tell our Rust program where to find the "Duration" code:

    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
    use std::time::Duration;
    
    fn main() {
      // Create an output stream
      let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
    } // main()
    

    And then we use the "build a new sine wave" code in rodio to build a new sine wave, which we'll name "source".

    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
    use std::time::Duration;
    
    fn main() {
      // Create an output stream
      let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
     
      // Generate a sine wave
      let source = SineWave::new(800.0).take_duration(Duration::from_secs_f32(0.5));
    } // main()
    

    The new definition for our sine wave will be stored in a variable called "source". The SineWave::new(800.0) uses rodio to build a new sine wave of 800 hertz; this code expects the frequency in hertz, and for it to be a "float" type of value (containing a decimal point) instead of an "integer" (which doesn't contain decimal points, as they are whole, or complete, numbers, not fractional).

    The .take_duration(Duration::from_secs_f32(0.5)); piece tells the "build a new sine wave" method that the duration of the sine wave will be 0.5 seconds. We could have also written this as .take_duration(Duration::from_millis(500));, because 500 milliseconds is just another way to describe half a second.

    The third thing we need to do is to play the sine wave through our output handler:

    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
    use std::time::Duration;
    
    fn main() {
      // Create an output stream
      let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
    
      // Generate a sine wave
      let source = SineWave::new(800.0).take_duration(Duration::from_secs_f32(0.5));
     
      // Play the tone
      let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
      
    } // main()
    

    Because the "play_raw" method of an OutputStream handler returns a value, we have to receive that value, but again, since we won't be using that value, we just assign it to "_", which both tells Rust we won't be using this value and to not even bother giving it a name, unlike before, when we named our ignored variable as "_stream". (For reasons, the former value needs a name; try it without and see what happens.)

    You'd think that at this point, we'd be able to hear a tone, but surprisingly, the program doesn't wait the half-second to let the tone play before it continues to the next piece of the program, which in this case is the end of the program. So the program quits before the tone plays.

    The fix is to tell the program to pause for as long as the tone is defined to play:

    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
    use std::time::Duration;
    
    fn main() {
      // Create an output stream
      let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
    
      // Generate a sine wave
      let source = SineWave::new(800.0).take_duration(Duration::from_secs_f32(0.5));
    
      // Play the tone
      let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
     
      // Sleep for the duration of the tone
      std::thread::sleep(Duration::from_millis(500));
      
    } // main()
    

    Now we're ready to save our code, compile it, and run it to see if we're successful.

    $ cargo run

    Success! Yay!

  5. Make a 'dit' and a 'dah', and a character-separating silence

    1. Let's convert our sound-code into a 'dit'.

    2. src/main.rs
      use rodio::source::{SineWave, Source};
      use rodio::OutputStream;
      use std::time::Duration;
      
      fn main() {
      fn dit() {
        // Create an output stream
        let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
      
        // Generate a sine wave
        let source = SineWave::new(800.0).take_duration(Duration::from_secs_f32(0.5));
      
        // Play the tone
        let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
      
        // Sleep for the duration of the tone
        std::thread::sleep(Duration::from_millis(500));
      
      } // main()dit()
      
      fn main() {
        dit();
      } // main()
      

      This code should function exactly as it did before.

    3. Let's get rid of "magic numbers"

    4. However, the length of the 'dit' seems a little long. Also, "magic numbers" in our code, such as the 0.5 and the 500 and the 800,0, should usually be avoided. So let's swap out those "magic numbers" with meaningful constant names. So that the consts can be known throughout the program, we'll define them outside of any functions:

      src/main.rs
      use rodio::source::{SineWave, Source};
      use rodio::OutputStream;
      use std::time::Duration;
      
      const DIT_LENGTH: u64 = 100; // We'll use milliseconds
      const DAH_LENGTH: u64 = DIT_LENGTH * 3;
      const CHAR_SEPARATOR_LENGTH: u64 = DAH_LENGTH;
      const TONE_FREQUENCY: f32 = 800.0;
      
      fn dit() {
        // Create an output stream
        let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
      
        // Generate a sine wave
        let source = SineWave::new(800.0TONE_FREQUENCY).take_duration(Duration::from_secs_f32(0.5)from_millis(DIT_LENGTH));
      
        // Play the tone
        let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
      
        // Sleep for the duration of the tone
        std::thread::sleep(Duration::from_millis(500DIT_LENGTH));
      
      } // dit()
      
      fn main() {
        dit();
      } // main()
      
    5. Let's copy/paste 'dit' into a 'dah'

    6. src/main.rs
      use rodio::source::{SineWave, Source};
      use rodio::OutputStream;
      use std::time::Duration;
      
      const DIT_LENGTH: u64 = 100; // We'll use milliseconds
      const DAH_LENGTH: u64 = DIT_LENGTH * 3;
      const CHAR_SEPARATOR_LENGTH: u64 = DAH_LENGTH;
      const TONE_FREQUENCY: f32 = 800.0;
      
      
      fn ditdah() {
        // Create an output stream
        let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
      
        // Generate a sine wave
        let source = SineWave::new(TONE_FREQUENCY).take_duration(Duration::from_millis(DITDAH_LENGTH));
      
        // Play the tone
        let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
      
        // Sleep for the duration of the tone
        std::thread::sleep(Duration::from_millis(DITDAH_LENGTH));
      
      } // ditdah()
      
      fn dit() {
        // Create an output stream
        let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
      
        // Generate a sine wave
        let source = SineWave::new(TONE_FREQUENCY).take_duration(Duration::from_millis(DIT_LENGTH));
      
        // Play the tone
        let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
      
        // Sleep for the duration of the tone
        std::thread::sleep(Duration::from_millis(DIT_LENGTH));
      
      } // dit()
      
      fn main() {
        dit();
      } // main()
      
    7. Let's create a silent space to separate characters

    8. src/main.rs
      use rodio::source::{SineWave, Source};
      use rodio::OutputStream;
      use std::time::Duration;
      
      const DIT_LENGTH: u64 = 100; // We'll use milliseconds
      const DAH_LENGTH: u64 = DIT_LENGTH * 3;
      const CHAR_SEPARATOR_LENGTH: u64 = DAH_LENGTH;
      const TONE_FREQUENCY: f32 = 800.0;
      
      
      fn char_space() {
        // Sleep for the duration of the character spacing
        std::thread::sleep(Duration::from_millis(CHAR_SEPARATOR_LENGTH));
      } // char_space
      
      fn dah() {
        // Create an output stream
        let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
      
        // Generate a sine wave
        let source = SineWave::new(TONE_FREQUENCY).take_duration(Duration::from_millis(DAH_LENGTH));
      
        // Play the tone
        let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
      
        // Sleep for the duration of the tone
        std::thread::sleep(Duration::from_millis(DAH_LENGTH));
      
      } // dah()
      
      fn dit() {
        // Create an output stream
        let (_stream, stream_handle) = OutputStream::try_default().expect("Could not open default audio device.");
      
        // Generate a sine wave
        let source = SineWave::new(TONE_FREQUENCY).take_duration(Duration::from_millis(DIT_LENGTH));
      
        // Play the tone
        let _ = stream_handle.play_raw(source.convert_samples()).expect("Unable to play sine wave.");
      
        // Sleep for the duration of the tone
        std::thread::sleep(Duration::from_millis(DIT_LENGTH));
      
      } // dit()
      
      fn main() {
        dit();
      } // main()
      
    9. Let's play a 'dit', followed by a character space, followed by a 'dah'

    10. I'm showing the main() function only, not the entire program.

      src/main.rs
              
      fn main() {
        dit();
        char_space();
        dah();
      } // main()
      

      The timing may not be quite right after a fresh compile, so you may need to run it a second time after a fresh compile to hear it all.

  6. Let's create a message

  7. In reality, we're ready to start working on the code to listen for the keys from the keyboard that will represent our 'dit' and 'dah', but before we do that, just for fun, let's create a short Morse code message, "CQ".

    src/main.rs
    ...
    } // dit()
    
    fn c() {
      dah();
      dit();
      dah();
      dit();
      char_space();
    } // "C"
    
    
    fn q() {
      dah();
      dah();
      dit();
      dah();
      char_space();
    } // "Q"
    
    fn main() {
      dit();
      char_space();
      dah();
      c();
      q():
      
    } // main()
    

    Now if you cargo run your program, you should hear a Morse "C" followed by a "Q".

    I'm going to add "CQ CQ de KC5ENO KC5ENO K".

    src/main.rs
    ...
    } // dit()
    fn c() {
      dah();
      dit();
      dah();
      dit();
      char_space();
    } // "C"
    
    
    fn d() {
      dah();
      dit();
      dit();
      char_space();
    } // "D"
    
    fn e() {
      dit();
      char_space();
    } // "E"
    
    fn k() {
      dah();
      dit();
      dah();
      char_space();
    } // "K"
    
    fn n() {
      dah();
      dit();
      char_space();
    } // "N"
    
    fn o() {
      dah();
      dah();
      dah();
      char_space();
    } // "O"
    
    fn q() {
      dah();
      dah();
      dit();
      dah();
      char_space();
    } // "Q"
    
    fn _5() {
      dit();
      dit();
      dit();
      dit();
      dit();
      char_space();
    } // 5()
    
    fn main() {
      char_space(); // This line will hopefully help to keep from cutting off the first character.
      c();
      q();
      char_space();
      char_space();
      char_space();
      c();
      q();
      char_space();
      char_space();
      char_space();
      c();
      q();
      char_space();
      char_space();
      char_space();
      d();
      e();
      char_space();
      char_space();
      char_space();
      k();
      c();
      _5();
      e();
      n();
      o();
      char_space();
      char_space();
      char_space();
      k();
      c();
      _5();
      e();
      n();
      o();
      char_space();
      char_space();
      char_space();
      k();
    } // main()
    

    When you cargo run this, you shuld get a pretty good approximation of "CQ CQ CQ de KC5ENO KC5ENO K". You may notice the timing is not perfect. I'm unsure why that is, and don't know how to fix it, but it's close enough for my purposes. Before moving on, for kicks, try changing the DIT_LENGTH to 75.

  8. Let's move that "CQ" code into its own function.

  9. Let's also make the code more generic, so it can easily play other messages. Notice I only coded the characters needed for my message; if you want a different message, you'll have to add the code for those characters you need.

    src/main.rs
    
     ...
    
    fn _5() {
      dit();
      dit();
      dit();
      dit();
      dit();
      char_space();
    } // 5()
     
    fn play_message(msg: &str) {
      for (_, ch) in msg.chars().enumerate() { // Careful; this technique won't handle non-ASCII characters.
        match ch.to_ascii_lowercase() {
          ' ' => {
            char_space();
            char_space();
            char_space();
          }
          'c' => c(),
          'd' => d(),
          'e' => e(),
          'k' => k(),
          'n' => n(),
          'o' => o(),
          'q' => q(),
          '5' => _5(),
          _ => {}
        }
      }
    } // play_message()
    
    fn main() {
      char_space(); // This line will hopefully help to keep from cutting off the first character.
      c();
      q();
      char_space();
      char_space();
      char_space();
      c();
      q();
      char_space();
      char_space();
      char_space();
      c();
      q();
      char_space();
      char_space();
      char_space();
      d();
      e();
      char_space();
      char_space();
      char_space();
      k();
      c();
      _5();
      e();
      n();
      o();
      char_space();
      char_space();
      char_space();
      k();
      c();
      _5();
      e();
      n();
      o();
      char_space();
      char_space();
      char_space();
      k();
      play_message("cq cq cq de kc5eno kc5eno k");
    } // main()
    

    Now we're ready to move on and add the key portion to our project.

  10. Add the key

  11. It's a simple keyer. We'll just loop forever listening for one of three keys to be pressed: a left-arrow for a 'dah'; a right-arrow for a 'dit', and a 'q' (or 'Q') to quit the program.

    And rather than building all the code ourselves, reinventing the wheel, we can use a library already written for creating Text User Interfaces (TUIs). There are several, but termion is a good one:

    $ cargo add termion

    We'll add some use statements:

    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
    use std::io::{stdin, stdout, Write};
    use std::time::Duration;
    use termion::event::Key;
    use termion::input::TermRead;
    use termion::raw::IntoRawMode;
    
    const DIT_LENGTH: u64 = 100; // We'll use milliseconds
    const DAH_LENGTH: u64 = DIT_LENGTH * 3;
    const CHAR_SEPARATOR_LENGTH: u64 = DAH_LENGTH;
    const TONE_FREQUENCY: f32 = 800.0;
    
    ...
    
    

    And we'll add a use_keyer function.

    src/main.rs
    
     ...
    
    } // play_message()
    
    fn use_keyer() {
      let stdin = stdin(); // This will be for reading keypresses.
      let mut stdout = stdout().into_raw_mode().unwrap(); // This is for displaying the message.
    
      write!( // This clears stdout (the screen, usually), "home"s the cursor, hides the cursor,
        stdout, // and displays the message at "home".
          "{}{}q to exit. Left-arrow for 'dah'; right-arrow for 'dit'.{}",
          termion::clear::All,
          termion::cursor::Goto(1, 1),
          termion::cursor::Hide
      ).unwrap(); // Let's hope for no problems....
      stdout.flush().unwrap(); // Message doesn't display until we force a flush of stdout data.
    
      for c in stdin.keys() { // Forever loop, watching for keypresses.
        match c.unwrap() { // Check keypress for a match to "q", "Q", left-arrow, or right-arrow.
          Key::Char('q') | Key::Char('Q') => {
            write!(stdout, "{}", termion::cursor::Show).unwrap();
            break;
          }
          Key::Left => dah(),
          Key::Right => dit(),
          _ => {} // Any other key, ignore it.
        }
      }
    } // use_keyer()
    
    fn main() {
      play_message("cq cq cq de kc5eno kc5eno k");
      use_keyer();
    } // main()
    
  12. And that should be it. Enjoy your Morse Code keyer app!

  13. Here's the whole program:

    Cargo.toml
    [package]
    name = "cw_keyer"
    version = "0.1.0"
    edition = "2021"
    
    [dependencies]
    rodio = "0.19.0"
    
    src/main.rs
    use rodio::source::{SineWave, Source};
    use rodio::OutputStream;
    use std::io::{stdin, stdout, Write};
    use std::time::Duration;
    use termion::event::Key;
    use termion::input::TermRead;
    use termion::raw::IntoRawMode;
    
    const DIT_LENGTH: u64 = 75; // We'll use milliseconds
    const DAH_LENGTH: u64 = DIT_LENGTH * 3;
    const CHAR_SEPARATOR_LENGTH: u64 = DAH_LENGTH;
    const TONE_FREQUENCY: f32 = 800.0;
    
    fn char_space() {
      // Sleep for the duration of the character spacing
      std::thread::sleep(Duration::from_millis(CHAR_SEPARATOR_LENGTH));
    } // char_space
    
    fn dah() {
      // Create an output stream
      let (_stream, stream_handle) =
        OutputStream::try_default().expect("Could not open default audio device.");
    
      // Generate a sine wave
      let source = SineWave::new(TONE_FREQUENCY).take_duration(Duration::from_millis(DAH_LENGTH));
    
      // Play the tone
      let _ = stream_handle
        .play_raw(source.convert_samples())
        .expect("Unable to play sine wave.");
    
      // Sleep for the duration of the tone
      std::thread::sleep(Duration::from_millis(DAH_LENGTH));
    } // dah()
    
    fn dit() {
      // Create an output stream
      let (_stream, stream_handle) =
        OutputStream::try_default().expect("Could not open default audio device.");
    
      // Generate a sine wave
      let source = SineWave::new(TONE_FREQUENCY).take_duration(Duration::from_millis(DIT_LENGTH));
    
      // Play the tone
      let _ = stream_handle
        .play_raw(source.convert_samples())
        .expect("Unable to play sine wave.");
    
      // Sleep for the duration of the tone
      std::thread::sleep(Duration::from_millis(DIT_LENGTH));
    } // dit()
    
    fn c() {
      dah();
      dit();
      dah();
      dit();
      char_space();
    } // "C"
    
    fn d() {
      dah();
      dit();
      dit();
      char_space();
    } // "D"
    
    fn e() {
      dit();
      char_space();
    } // "E"
    
    fn k() {
      dah();
      dit();
      dah();
      char_space();
    } // "K"
    
    fn n() {
      dah();
      dit();
      char_space();
    } // "N"
    
    fn o() {
      dah();
      dah();
      dah();
      char_space();
    } // "O"
    
    fn q() {
      dah();
      dah();
      dit();
      dah();
      char_space();
    } // "Q"
    
    fn _5() {
      dit();
      dit();
      dit();
      dit();
      dit();
      char_space();
    } // 5()
    
    fn play_message(msg: &str) {
      for (_, ch) in msg.chars().enumerate() { // Careful; this technique won't handle non-ASCII characters.
        match ch.to_ascii_lowercase() {
          ' ' => {
            char_space();
            char_space();
            char_space();
          }
          'c' => c(),
          'd' => d(),
          'e' => e(),
          'k' => k(),
          'n' => n(),
          'o' => o(),
          'q' => q(),
          '5' => _5(),
          _ => {}
        }
      }
    } // play_message()
    
    fn use_keyer() {
      let stdin = stdin(); // This will be for reading keypresses.
      let mut stdout = stdout().into_raw_mode().unwrap(); // This is for displaying the message.
    
      write!( // This clears stdout (the screen, usually), "home"s the cursor, hides the cursor,
        stdout, // and displays the message at "home".
          "{}{}q to exit. Left-arrow for 'dah'; right-arrow for 'dit'.{}",
          termion::clear::All,
          termion::cursor::Goto(1, 1),
          termion::cursor::Hide
      ).unwrap(); // Let's hope for no problems....
      stdout.flush().unwrap(); // Message doesn't display until we force a flush of stdout data.
    
      for c in stdin.keys() { // Forever loop, watching for keypresses.
        match c.unwrap() { // Check keypress for a match to "q", "Q", left-arrow, or right-arrow.
          Key::Char('q') | Key::Char('Q') => {
            write!(stdout, "{}", termion::cursor::Show).unwrap();
            break;
          }
          Key::Left => dah(),
          Key::Right => dit(),
          _ => {} // Any other key, ignore it.
        }
      }
    } // use_keyer()
    
    fn main() {
        play_message("cq cq cq de kc5eno k");
        use_keyer();
    } // main()
    
Top of Page