Modular Computers the manual GitHub

Sound Card

A sound card plays the samples a program writes to it, as a real card’s DAC does. The players near its block hear them.

Facts

| | | |—|—| | Type | sound (the kernel names it dsp, then dsp1) | | Address | An expansion slot path such as exp:0, or a hub’s | | Formats | Rates 8000, 11025, 22050 or 44100 a second; unsigned 8-bit (u8), signed 16-bit little-endian (s16le) or IMA ADPCM (ima_adpcm); 1 or 2 channels | | Buffer | One second of the current format | | Interrupt | Raised when a refused write has room, or when what is queued has played out | | Power | 6 W | | Info | kind (sound) |

How it plays

A write takes what fits in the buffer and says how much (nothing is dropped silently). A program that is refused waits for the card’s interrupt, which comes when at least half the buffer is free. The card plays by game time: each tick its block sends what is due, still encoded, to the players within 40 blocks, and their games decode and play it. It decodes IMA ADPCM itself.

Mono comes from the card’s block. Stereo is two speakers half a block either side of the block, its left and right as seen facing its front, because the game does not place a stereo sound in the world. See Faces.

The card powers on at OSS’s defaults: 8000 a second, unsigned 8-bit, mono. Changing the format drops what is queued. A new format that is the same as the old one drops nothing.

In mcos

The card is OSS’s /dev/dsp (/dev/dsp1 for the next card), as on Linux.

cat /clip.raw > /dev/dsp
aplay /clip.wav

aplay plays WAV files and sets the format for them. The file’s ioctls are OSS’s:

ioctl Argument Does
SNDCTL_DSP_SPEED a rate sets the rate; answers what it is
SNDCTL_DSP_SETFMT AFMT_U8, AFMT_S16_LE or AFMT_IMA_ADPCM sets the encoding; answers its name
SNDCTL_DSP_CHANNELS 1 or 2 sets the channels; answers them
SNDCTL_DSP_GETODELAY none gives the bytes queued
SNDCTL_DSP_RESET none drops what is queued
SNDCTL_DSP_SYNC none waits until what is queued has played

A value the card does not play fails with EINVAL (an unknown format name too). After OSS’s, the file’s ioctls are the card’s own methods below, by name, with their arguments: an ADPCM block’s size, which OSS never had, is

sys.ioctl(dsp, "setFormat", {block = 1024})

A method’s ioctl answers what the method does (true for one that answers nothing); its failure is nil, the card’s message and EINVAL. setLabel sets the card’s label, as on every device file.

Methods

setFormat({rate, encoding, channels, block}) → nothing

Sets what it plays. Any field left out stays. encoding is "u8", "s16le" or "ima_adpcm". An unsupported value fails with a message such as unsupported rate: 9000 ([8000, 11025, 22050, 44100]), unsupported encoding: ... or unsupported channels: 3 (1 or 2). block is for ADPCM only (WAV’s block align: at most 8192, a multiple of 4 times the channels, and larger than 4 bytes a channel, else bad ADPCM block size: ...); switching to ADPCM without one gives 512 bytes a channel. What was queued goes, unless the new format is the same as the old.

getFormat() → {rate, encoding, channels, block}

The current format. block is 0 unless the encoding is ADPCM.

write(data) → count

Queues bytes to play and gives how many it took: fewer, or 0, when the buffer is full. Then its interrupt comes once half the buffer is free again. (Writing to the file does this for you, and waits.)

getDelay() → bytes

The bytes queued and not yet played (OSS’s SNDCTL_DSP_GETODELAY).

sync() → boolean

True once what is queued has played out. False: not yet, and the interrupt says when (OSS’s SNDCTL_DSP_SYNC, which waits for it).

reset() → nothing

Drops what is queued, at once (OSS’s SNDCTL_DSP_RESET).

Events

None to pull. The interrupt line is the event: it is up while a write was refused and the buffer is at least half empty, and while a sync waits and the queue has played out. Each write and sync starts its wait afresh.

Examples

A tune, played through the file at OSS’s defaults (the program only makes samples and writes them):

-- Play a tune on the sound card: lua sound-melody.lua [notes]
--
-- The notes are names with an octave, a beat each (C4 D4 E4...), and "-" is a rest. Each note is a
-- square wave, made as samples at the card's defaults (OSS's: 8000 a second, unsigned 8-bit, mono),
-- so the program just writes them to /dev/dsp, as `cat file.raw > /dev/dsp` would. The card's
-- buffer holds a second; a write waits while it is full, so the program never runs ahead of the
-- music and needs no timer of its own.
local RATE = 8000        -- samples a second: the default every open of /dev/dsp starts at
local BEAT = 0.25        -- seconds a note lasts
local GAP = 0.02         -- seconds of silence at a note's end, so repeated notes are heard apart
local SILENCE = 128      -- an unsigned 8-bit sample's middle: no sound
local VOLUME = 60        -- how far the wave swings from the middle (at most 127)
local TUNE = "E4 D4 C4 D4 E4 E4 E4 - D4 D4 D4 - E4 G4 G4 -"

local SEMITONES = {C = -9, D = -7, E = -5, F = -4, G = -2, A = 0, B = 2}

-- A note's pitch in Hz, from its name ("A4" is 440; a "#" after the letter is a semitone up).
local function pitch(name)
  local letter, sharp, octave = name:match("^(%u)(#?)(%d)$")
  assert(letter and SEMITONES[letter], "not a note: " .. name)
  local steps = SEMITONES[letter] + (sharp == "#" and 1 or 0) + (tonumber(octave) - 4) * 12
  return 440 * 2 ^ (steps / 12)
end

-- A beat of samples: the note's square wave, then the gap (a rest is all silence).
local function samples(name)
  local count = math.floor(BEAT * RATE)
  local sounding = name == "-" and 0 or count - math.floor(GAP * RATE)
  local period = name ~= "-" and RATE / pitch(name)
  local high, low = string.char(SILENCE + VOLUME), string.char(SILENCE - VOLUME)
  local out = {}
  for i = 0, count - 1 do
    if i < sounding then
      out[#out + 1] = (i % period) < period / 2 and high or low
    else
      out[#out + 1] = string.char(SILENCE)
    end
  end
  return table.concat(out)
end

local dsp = assert(io.open("/dev/dsp", "w"))
for name in (arg[1] or TUNE):gmatch("%S+") do
  dsp:write(samples(name))  -- waits while the card's buffer is full
  dsp:flush()
end
dsp:close()  -- what is queued plays on after the program ends

A doorbell: a redstone card hears the button, and the program sets a 16-bit format with OSS’s ioctls and waits with SNDCTL_DSP_SYNC for each ring to end:

-- A doorbell: when a button wired to the computer is pressed, play a two-tone chime ("ding-dong").
--
-- The button's signal comes in on a redstone card (its file, /dev/redstone0, a line a face:
-- "face level powered"). The chime is 16-bit, so the program sets the sound card with OSS's
-- ioctls (format, channels, rate), as any Linux program sets /dev/dsp, and makes the chime once.
-- After each ring, SNDCTL_DSP_SYNC waits until it has played out, so a second press while it
-- rings does not pile up rings. sys.poll waits for the redstone card's change (PRI): no busy wait.
local sys = require("sys")

local BUTTON = "north"   -- the face the button's signal comes in on
local RATE = 11025       -- samples a second
local DING, DONG = 660, 520  -- the two tones' pitches, in Hz
local TONE = 0.4         -- seconds each tone lasts
local VOLUME = 12000     -- the loudest sample (16-bit samples go to 32767)

-- A tone fading out, as a struck chime does: a sine wave whose loudness falls away.
local function tone(hz)
  local count = math.floor(TONE * RATE)
  local out = {}
  for i = 0, count - 1 do
    local fade = 1 - i / count
    out[#out + 1] = string.pack("<i2", math.floor(VOLUME * fade * fade * math.sin(2 * math.pi * hz * i / RATE)))
  end
  return table.concat(out)
end

local chime = tone(DING) .. tone(DONG)

local dsp = assert(sys.open("/dev/dsp", "w"))
assert(sys.ioctl(dsp, "SNDCTL_DSP_SETFMT", "AFMT_S16_LE"))
assert(sys.ioctl(dsp, "SNDCTL_DSP_CHANNELS", 1))
assert(sys.ioctl(dsp, "SNDCTL_DSP_SPEED", RATE))

local redstone = assert(io.open("/dev/redstone0"))
local watch = {[sys.fileno(redstone)] = {PRI = true}}

-- The button's level now: the file read again from its start.
local function pressed()
  redstone:seek("set", 0)
  return (tonumber(redstone:read("a"):match(BUTTON .. " (%d+)")) or 0) > 0
end

local was = pressed()
while true do
  sys.poll(watch)  -- block until the redstone card changes
  local now = pressed()
  if now and not was then
    sys.write(dsp, chime)            -- waits while the card's buffer is full
    sys.ioctl(dsp, "SNDCTL_DSP_SYNC") -- and until it has all played
  end
  was = now
end

A stereo siren through the card’s own methods: write’s handshake with poll’s PRI, sync to finish, and reset to stop at once when touch /tmp/siren.off asks:

-- An alarm siren: lua sound-siren.lua [seconds]
--
-- A tone sweeps up and down and swings between the card's two speakers, for SECONDS, or until
-- someone runs `touch /tmp/siren.off`. It drives the sound card through its own methods, which are
-- its /dev/dsp file's ioctls: setFormat (stereo), getFormat, write (which takes only what fits in
-- the buffer and says how much: the program waits for the card's news, poll's PRI, before it
-- writes the rest), sync (has it all played?), getDelay (what is still queued) and reset (drop it,
-- so the siren stops at once rather than playing on for its last second).
local sys = require("sys")

local SECONDS = tonumber(arg[1]) or 10  -- how long it sounds, unless stopped
local RATE = 8000        -- frames a second (each frame a left and a right sample)
local CHUNK = 0.25       -- seconds of sound made at a time
local SWEEP = 1          -- seconds for the tone to rise and fall back, and the sound to cross over
local LOW, HIGH = 500, 1000  -- the sweep's pitches, in Hz
local SILENCE = 128      -- an unsigned 8-bit sample's middle
local VOLUME = 90        -- how far a wave swings from it
local STOP = "/tmp/siren.off"  -- the file that stops it

local dsp = assert(sys.open("/dev/dsp", "w"))
assert(sys.ioctl(dsp, "setFormat", {rate = RATE, encoding = "u8", channels = 2}))
local format = sys.ioctl(dsp, "getFormat")
print(("siren: %d Hz, %s, %d channels"):format(format.rate, format.encoding, format.channels))

-- Frames 'from' to 'from + count - 1' of the siren, as bytes: left, right, left, right...
local phase = 0
local function frames(from, count)
  local out = {}
  for i = from, from + count - 1 do
    local t = (i / RATE) % SWEEP / SWEEP           -- where in the sweep, 0 to 1
    local rise = t < 0.5 and t * 2 or 2 - t * 2    -- up, then down
    phase = phase + (LOW + (HIGH - LOW) * rise) / RATE
    local wave = phase % 1 < 0.5 and VOLUME or -VOLUME
    out[#out + 1] = string.char(SILENCE + math.floor(wave * (1 - rise)), SILENCE + math.floor(wave * rise))
  end
  return table.concat(out)
end

-- Queue all of 'data', waiting for room as the card plays: false if told to stop meanwhile.
local function play(data)
  while #data > 0 do
    local taken = assert(sys.ioctl(dsp, "write", data))
    data = data:sub(taken + 1)
    if #data > 0 then
      sys.poll({[dsp] = {PRI = true}})  -- the card says when half its buffer is free
    end
    if sys.stat(STOP) then
      return false
    end
  end
  return true
end

local total, step = math.floor(SECONDS * RATE), math.floor(CHUNK * RATE)
local stopped = false
for from = 0, total - 1, step do
  if not play(frames(from, math.min(step, total - from))) then
    stopped = true
    break
  end
end

if stopped then
  local queued = sys.ioctl(dsp, "getDelay")
  sys.ioctl(dsp, "reset")  -- silence, now
  sys.unlink(STOP)
  print(("siren: stopped (%d bytes not played)"):format(queued))
else
  while not sys.ioctl(dsp, "sync") do  -- false: not played out yet, and the card will say when
    sys.poll({[dsp] = {PRI = true}})
  end
  print("siren: done")
end
sys.close(dsp)

Unsigned 8-bit samples centre on 128 (silence), so a square wave is a run of bytes above it and a run below. A 16-bit sample is string.pack("<i2", value), from -32768 to 32767.