Консольный проигрыватель .wav для pc-speaker в Linux

    Давно хотел написать проигрыватель для pc-speaker и чтобы не только ноты и монофонические мелодии. Но в то время когда это было актуально (DOS — навсегда!) у меня не было ни знаний, ни способностей, ни помыслов. Позже я не смог пробиться к нему сквозь Windows DDK и продолжал тихо пищать в стиле QBASIC SOUND. Да и актуальность pc-speaker как звукового устройства стала нулевой, гордый speaker превратился в beeper и buzzer. Однако он никуда не исчез из ПК (попутно пережив все дисководы) по прежнему давая о себе знать при включении и сообщая об ошибках. Так можно ли в современном программно-аппаратном user-space окружении проиграть полифоническую мелодию или голос на pc-speaker? Конечно можно — Си и Linux нам в этом помогут.
    Посвящается, мужику в шляпе и очках, посылающему всех в известном направлении (неизвестного мне автора, всё хорошо работает в DOSBox).
    Что представляет собой спикер с точки зрения программиста? Это устройство с двумя состояниями: включен и выключен — этим мы управляем мембраной (наверное, а может каким нибудь другим активным элементом) которая издаёт звук. Порог слышимости человека по заверениям биологов около 22КГц сверху и 20Гц снизу, поэтому мы должны переключать состояния очень быстро. Обычно спикер управляется интервальным таймером, но этим способом мы можем проигрывать звуки только заданной частоты и длительности, для чего существуют готовые программные (получать доступ к таймеру напрямую совсем не обязательно) и пользовательские интерфейсы. Например, для консоли Linux нота «До» первой октавы, длительностью 1 секунда:

    echo -en "\e[10;263;11;1000]\a"
    echo -en "\ec"
    

    Вторая команда echo возвращает установки длительности и частоты звука в состояние по умолчанию.
    Для доступа к управлению спикером напрямую надо общаться с 0x61 (шестнадцатеричные 61) портом, с его нулевым и первым битами. Нулевой бит: управляет привязкой спикера к таймеру — если 1 то управляется таймером. Первый бит: переключает состояния спикера — 1 включен, 0 выключен. Этот тот способ который мы будем использовать.
    Теперь чтобы понять как играть мы должны определится что же мы будем играть. Для проигрывания будем использовать .WAV файлы. Формат данных .WAV файла может быть разнообразным, но обычно под ними подразумеваются файлы содержащие данные в импульсно кодовой модуляции. Это последовательность значений получаемых с заданной частотой (частотой дискретизации) с АЦП и записанных в файл как есть, для каждого из каналов. С точки зрения звука — это громкость в данный момент времени или с точки зрения динамика — положение мембраны относительно точки покоя. Максимальное значение полученное с AЦП за раз, задаёт величину битового потока (bitrate). Совокупность записей данных обо всех каналах в данный момент времени, это сэмпл. Говоря про звук надо обязательно вспомнить про теорему Котельникова, и сказать что по этим данным мы имеем возможность восстановить исходную волну (при больших значениях частоты дискретизации и битового потока) без потерь. Но в нашем случае это не сработает, потому что у спикера нет понятие громкости, точнее она есть, но её нельзя менять — или вся громкость (включен) или её нет (выключен). Учитывая что дискретизация по громкости AЦП также имеет конечную точность, становится ясна ценность «тёплого лампового звука».
    Записанные данные представлены в виде положительных и отрицательных значений (движение волны которая пересекает ось абсцисс), но несколько в ином формате: за ноль (тишину) принимается половина от максимального значения. Если у нас данные представлены однобайтными отсчётами, то ноль будет 256/2 = 128. 256 это максимальное возможное количество чисел которое может быть представлено одним байтом, или 256 = 255(0xFF, максимальное значение числа) + 1. Соответственно, если для записи используются 2-а байта, то (65535 + 1)/2 = 32768. Значения больше этого числа являются положительными, и растут от меньшего к большему, значения меньше этого числа отрицательные, и уменьшаются от большего к меньшему. Например, переведём в обычное число для однобайтных значений:

    245(значение АЦП) => 117 = 245 - 128
     93(значение АЦП) => -35 =  93 - 128
    

    Как уже было сказано выше, мы можем только включить и выключить спикер, то есть из полученных данных мы сформируем прямоугольную волну, где 1 — это значения больше ноля, 0 — во всех остальных случаях. Больше подробностей про то, как играть на спикере, можно посмотреть здесь, эта статья послужила отправной точкой в задуманной реализации, большое спасибо за это её автору.
    Начнём осуществлять задуманное. Сразу оговорюсь, что проигрывается только один канал, максимальный размер данных для анализа 4 байта на канал (учитывая что для спикера хватить и одного бита для максимального возможного качества, то это величина избыточна в 32 раза). С самого начала, я видел две большие проблемы:
    Первая, доступ к портам — как всё просто было в DOS, до такой же степени всё сложно было для меня в Windows. Однако Linux предоставляет совершенно шикарнейшую возможность прямого доступа к портам, надо только знать пароль root, или каким нибудь другим способом получить права суперпользователя, а точнее привилегию CAP_SYS_RAWIO для создаваемого процесса. Эта возможность называется ioperm и позволяет открыть доступ ко всем портам в диапазоне от 0 до 0x3FF. Хочется больше, используем другой вызов — iopl, нам он не пригодится.
    Доступ непосредственно к портам ввода вывода, после разрешения, осуществляется с помощью макросов описывающий inline вставки на ассемблере. Можно записывать(out) и читать(in) байты(b), слова(w), двойные слова(l), строки(s), использовать паузу (_p) после операции. MAN страница содержит крайне мало информации и больше пугает что так делать не стоит, поэтому лучше смотреть в исходники заголовочных файлов. Если использовать макросы с _p, то дополнительно надо открыть доступ к 0x80 порту, потому что задержка выполняется выводом байта данных в этот порт.
    В программе — сначала инициализируем доступ к портам, сохранив значение которое уже было в 0x61 порту, для восстановления после работы программы:

    #define SPKPORT 0x61
    
    static unsigned char old61 = 0;
    unsigned char out61 = 0;
    
    if (!ioperm(SPKPORT,1,1)){ //Разрешили доступ к 0x61 порту
    	old61 = inb(SPKPORT); //Прочитали 
    	out61 = old61 & 0xFE; //Обнулили нулевой бит
    	outb(old61,SPKPORT); //Записали, тем самым отсоединив спикер от таймера
    }
    

    В конце, вернём как было:

    outb(old61,SPKPORT); //Восстановим сохранённое значение
    ioperm(SPKPORT,1,0); //Запретим сами себе разрешённый доступ к 0x61 порту
    

    Вторая проблема — время. Linux многозадачная многопользовательская среда и на монопольное непрерывное владение ресурсом (в частности процессором) рассчитывать не приходится. Чтобы проиграть звук, максимально приближено (с учётом что это pc-speaker) к оригиналу, мы должны посылать данные в строго установленном интервале, отклонения от этого интервала мгновенно исказят звук. Если интервал будет ритмичным (одинаковым), но длиннее либо короче, проигрываемые звуки будут соответственно ниже либо выше исходных. Если интервал будет каждый раз разный, звук будет просто не узнать. Всё это осложняется тем, что минимальная частота дискретизации 8000Гц, что вынуждает нас задавать самые длинные интервалы не больше 1/8000 ~ 125микросекунд. При 22КГц, это уже 45 микросекундные интервалы. Такие задержки, если верить MAN, возможны при использовании usleep или nanosleep. Но сначала, где их надо было делать:

    char wavdata[0x10000]; //Буфер с данными
    unsigned int *curdata; //Указатель на текущие данные из буфера
    unsigned int bufsize; //Объём данные в буфере
    
    unsigned int cursampleraw; //Текущее значение из буфера
    unsigned int datamask; //Маска соответствующая размеру данных на значение в канале
    short int onechannelinc; //Приращение позиции данных в буфере = размер сэмпла
    unsigned int samplezero; //Значение нулевого уровня
    
    for (i = 0;i < bufsize;i += onechannelinc){
    
    	curdata = (void*)(wavdata+i); //Определили указатель на текущую позицию
    	cursampleraw = *curdata&datamask; //Выделили значимое для нас
    	  		
    	if (cursampleraw > samplezero){ //Если положительная часть волны
      		out61 |= 0x2; //Включить спикер, выставляем 1 бит = 1
      	}else{
      		out61 &= 0xFD; //Иначе выключить, выставляем 1 бит = 0
    	}
    	outb(out61,SPKPORT); //Вывели в порт
    
    //Здесь надо подождать, перед записью следующего значения
    
    }
    

    Это и есть практически вся программа, всё остальное — чтение из файла и предварительный анализ данных.
    Так вот, как же с задержками? Использование usleep и nanosleep не дало никаких результатов, точнее они дали результаты, но при значениях паузы меньше 10 микросекунд. Если пауза была больше, звук ломался непоправимо, и дело не высоте звучания, а в том что пауза не выдерживалась, не было ритмичности, то есть каждый проход цикла была разной длительности. Подумав что процесс имеет слишком низкий приоритет — используем nice. Но ни утилита, ни программный вызов проблему не решили. Осталось попробовать поменять политику планировщика:

    struct sched_param schedio;
    
    sched_getparam(0,&schedio);	 //Получили текущие политики
    schedio.sched_priority = sched_get_priority_max(SCHED_FIFO); //Поставили максимальный приоритет
    sched_setscheduler(0,SCHED_FIFO,&schedio); //Применили политику SCHED_FIFO
    

    Я не уверен в правильности использования, но в таком виде не помогло (данный код остался в программе закомментированным, на случай если это всё же помогает, то его можно будет вернуть в дело, также как и nice). Всё это было перепробовано следуя рекомендациям с этой страницы. Оставалось попробовать только пустой цикл, вместо usleep… и он дал результат — можно было слышать не только музыку, но и речь.
    Всё работало. Это вселяло надежду, но предвещало плохие последствия при переносе на другие машины. В последней ссылке, было пару абзацев про то, что вывод в порты даёт задержку около 1 микросекунды, я отнёсся к этому скептически, хотя макрос outb_p для вывода в порт с задержкой, руководствовался тем же принципом. В главный цикл добавили паузу:

    short int pause;
    
    for (i = 0;i < bufsize;i += onechannelinc){
    
    	curdata = (void*)(wavdata+i);
    	cursampleraw = *curdata&datamask;
    	  		
    	if (cursampleraw > samplezero){
      		out61 |= 0x2;
      	}else{
      		out61 &= 0xFD;
    	}
    	
    	for (k=0;k<pause;k++)outb(out61,SPKPORT); //Совмещаем задержку и вывод
    }
    

    — после чего программа была опробована на рабочем сервере, неожиданно для меня, ничего не ломалось и всё работало. Возможных проблем чтения из файла, во время проигрывания (замираний между циклами чтения), также не возникло ни на одном из испробованных компьютеров: файл читается буфером в 64КБ, при это заметного на слух искажения не происходит. Наличие любого постороннего кода в главном цикле, никак не влияет на качество звука, если только этот код не системные вызовы. В итоге, я пришёл к выводу, что чем мощнее компьютер тем лучше будет звучать наш спикер, как это не парадоксально. Если идти в сторону уменьшения мощности то в какой-то момент на многозадачных системах всё сломается, но перейдя на однозадачные, мы вновь добьёмся результата.
    Честно говоря я не был уверен в каком бы то ни было положительном исходе, так как нормальных вариантов этой программы для DOS в своё время было два. Первый, запрещаем все прерывания (остаёмся монопольным владельцем всего) и выполняем код в лоб как это приведено выше, считая задержки по тактам в бесконечном цикле. Второй, настраиваем таймер на максимальную частоту (минимальный интервал как раз около 1 микросекунды), забираем себе прерывание 0x8 (IRQ0) и выдаём данные спикеру через собственные подсчитанные интервалы, не давая никому вмешиваться в этот процесс. Оба этих варианта в user-space окружении Linux неработоспособны, но я рад что всё получилось вот так странно.
    Теперь несколько строк о том что в остальной части кода. В основном это разбор заголовка .WAV файла, найдя вот это описание, я разобрал все поля, провёл по ним проверки, но первый же скачанный из интернета файл оказался не того формата. Затем обратившись к этому документу и упростив разбор, учтя новые данные, скачал из интернета второй файл, который поставил меня в тупик отсутствием поля в 2-а байта перед цепочкой fact, в итоге пришлось ещё сократить проверки, чтобы добиться выполнения какого-то разумного количества произвольных файлов.
    В анализе заголовка .WAV содержится проверка на типы данных, с использованием конструкции sizeof, предполагается что используемые типы данных int в 4 байта, short int в 2 байта и char — 1 байт. В таком виде можно компилировать и для 64-х битных систем. Если это не так, то проверка не должна пройти, и программа выдаст ошибку о не поддерживаемом формате .WAV.
    Также в коде осталась попытка визуализировать процесс, но совмещая со звуком, получаем только кваканье и треск, поэтому график можно увидеть, но без звука. Кстати можно оценить насколько медленнее происходит проигрывание (или сравнить свои визуальные и звуковые ощущения), при использовании usleep в качестве задержки.
    Это моя первая программа специально для Linux на компилируемом языке, поэтому для тех кто ищет conio.h из Borland C, здесь его нет, но тут всё гораздо лучше: ESC-последовательности (или man console_codes) заменяют почти всё, кроме kbhit (это просто один из режимов чтения), и получения размеров экрана консоли, но тут надо обратиться к устройству напрямую с помощью ioctl (man console_ioctl):

    struct winsize scrsize;
    
    ioctl(STDOUT_FILENO,TIOCGWINSZ,&scrsize);
    
    //scrsize.ws_col - размеры по горизонтали
    //scrsize.ws_row - размеры по вертикали
    

    Программу можно забрать здесь: playwav.zip — в архив включены также несколько .WAV файлов. Компилировать можно просто, в том числе проверено и на 64-х битных системах:
    gcc playwav64.c -o playwav64
    chmod +x playwav64
    

    Запускать можно с тремя параметрами, первый — файл для проигрывания,

    sudo ./playwav64 file.wav
    

    второй — умножитель времени: чем он больше тем тон ниже, если второй параметр не число или его нет, то значение используется по умолчанию 650000,

    sudo ./playwav64 file.wav 500000
    

    третий — любое значение, сообщает программе что надо вывести график на экран (без звука).

    ./playwav64 file.wav s w
    

    Звук только с root привилегиями. Запускать лучше не в иксах, хотя в KDE тоже работает. Если запускать в SSH сессии, то звук будет на физической машине к которой выполнено подключение.
    Проверил максимально, на всех доступных мне компьютерах (работало везде), возможно в коде много напортачил, прошу за это прощения.
    Support the author
    Share post

    Comments 3

      +1
      Тоже думал над этой темой, но позабыл ненайдя никакой информации. Вам спасибо, что раскопали.
        +1
        Спасибо, вспомнил молодость :) Вспомнил файлы hello.com и snddemo.com. Первый под звуки фанфар жизнерадостно возвещал «здравствуй, жопа!», второй играл зацикленный кусок какой то мелодии.

        А еще дисководом музыку играли :) И принтером. Хех.
          +1
          Спасибо всем кто помнит, странно наверное, но у меня просто невозможный детский восторг после того как всё заработало — перепробовал проиграть все файлы до которых добрался :)

          Only users with full accounts can post comments. Log in, please.