You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
575 lines
15 KiB
575 lines
15 KiB
4 years ago
|
/*
|
||
|
* import_alsa.c -- module for importing audio through ALSA
|
||
|
* (C) 2008-2010 - Francesco Romani <fromani at gmail dot com>
|
||
|
*
|
||
|
* This file is part of transcode, a video stream processing tool.
|
||
|
*
|
||
|
* transcode is free software; you can redistribute it and/or modify
|
||
|
* it under the terms of the GNU General Public License as published by
|
||
|
* the Free Software Foundation; either version 2 of the License, or
|
||
|
* (at your option) any later version.
|
||
|
*
|
||
|
* transcode is distributed in the hope that it will be useful,
|
||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
|
* GNU General Public License for more details.
|
||
|
*
|
||
|
* You should have received a copy of the GNU General Public License
|
||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||
|
*/
|
||
|
|
||
|
|
||
|
|
||
|
#include "transcode.h"
|
||
|
#include "libtc/optstr.h"
|
||
|
|
||
|
#include "libtc/tcmodule-plugin.h"
|
||
|
|
||
|
#include "config.h"
|
||
|
|
||
|
#include <alsa/asoundlib.h>
|
||
|
#ifdef HAVE_GETTIMEOFDAY
|
||
|
# include <sys/time.h>
|
||
|
# include <time.h>
|
||
|
#endif
|
||
|
#include <string.h>
|
||
|
|
||
|
/*%*
|
||
|
*%* DESCRIPTION
|
||
|
*%* This module reads audio samples from an ALSA device using libalsa.
|
||
|
*%*
|
||
|
*%* BUILD-DEPENDS
|
||
|
*%* alsa-lib >= 1.0.0
|
||
|
*%*
|
||
|
*%* #DEPENDS
|
||
|
*%*
|
||
|
*%* PROCESSING
|
||
|
*%* import/demuxer
|
||
|
*%*
|
||
|
*%* MEDIA
|
||
|
*%* audio
|
||
|
*%*
|
||
|
*%* #INPUT
|
||
|
*%*
|
||
|
*%* OUTPUT
|
||
|
*%* PCM*
|
||
|
*%*
|
||
|
*%* OPTION
|
||
|
*%* device (string)
|
||
|
*%* selects ALSA device to use for capturing audio.
|
||
|
*%*/
|
||
|
|
||
|
#define LEGACY 1
|
||
|
|
||
|
#ifdef LEGACY
|
||
|
# define MOD_NAME "import_alsa.so"
|
||
|
#else
|
||
|
# define MOD_NAME "demultiplex_alsa.so"
|
||
|
#endif
|
||
|
|
||
|
#define MOD_VERSION "v0.0.5 (2007-05-12)"
|
||
|
#define MOD_CAP "capture audio using ALSA"
|
||
|
|
||
|
#define MOD_FEATURES \
|
||
|
TC_MODULE_FEATURE_DEMULTIPLEX|TC_MODULE_FEATURE_AUDIO
|
||
|
#define MOD_FLAGS \
|
||
|
TC_MODULE_FLAG_RECONFIGURABLE
|
||
|
|
||
|
static const char tc_alsa_help[] = ""
|
||
|
"Overview:\n"
|
||
|
" This module reads audio samples from an ALSA device using libalsa.\n"
|
||
|
"Options:\n"
|
||
|
" device=dev selects ALSA device to use\n"
|
||
|
" help produce module overview and options explanations\n";
|
||
|
|
||
|
|
||
|
/*
|
||
|
* TODO:
|
||
|
* - device naming fix (this will likely require some core changes)
|
||
|
* - probing/integration with core
|
||
|
* - suspend recovery?
|
||
|
* - smarter resync?
|
||
|
*/
|
||
|
|
||
|
/*************************************************************************/
|
||
|
|
||
|
typedef struct tcalsasource_ TCALSASource;
|
||
|
struct tcalsasource_ {
|
||
|
snd_pcm_t *pcm;
|
||
|
|
||
|
int rate;
|
||
|
int channels;
|
||
|
int precision;
|
||
|
};
|
||
|
|
||
|
|
||
|
/*************************************************************************/
|
||
|
/* some support functions shamelessly borrowed^Hinspired from alsa-utils */
|
||
|
/*************************************************************************/
|
||
|
|
||
|
#ifdef HAVE_GETTIMEOFDAY
|
||
|
|
||
|
#define TIMERSUB(a, b, result) do { \
|
||
|
(result)->tv_sec = (a)->tv_sec - (b)->tv_sec; \
|
||
|
(result)->tv_usec = (a)->tv_usec - (b)->tv_usec; \
|
||
|
if ((result)->tv_usec < 0) { \
|
||
|
--(result)->tv_sec; \
|
||
|
(result)->tv_usec += 1000000; \
|
||
|
} \
|
||
|
} while (0)
|
||
|
|
||
|
#endif
|
||
|
|
||
|
#define ALSA_PREPARE(HANDLE) do { \
|
||
|
int ret = snd_pcm_prepare((HANDLE)->pcm); \
|
||
|
if (ret < 0) { \
|
||
|
tc_log_error(MOD_NAME, "ALSA prepare error: %s", snd_strerror(ret)); \
|
||
|
return TC_ERROR; \
|
||
|
} \
|
||
|
} while (0)
|
||
|
|
||
|
/* I/O error handler */
|
||
|
static int alsa_source_xrun(TCALSASource *handle)
|
||
|
{
|
||
|
snd_pcm_status_t *status = NULL;
|
||
|
snd_pcm_state_t state = 0;
|
||
|
int ret = 0;
|
||
|
|
||
|
TC_MODULE_SELF_CHECK(handle, "alsa_source_xrun");
|
||
|
|
||
|
snd_pcm_status_alloca(&status);
|
||
|
ret = snd_pcm_status(handle->pcm, status);
|
||
|
if (ret < 0) {
|
||
|
tc_log_error(__FILE__, "error while fetching status: %s",
|
||
|
snd_strerror(ret));
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
|
||
|
state = snd_pcm_status_get_state(status);
|
||
|
|
||
|
if (state == SND_PCM_STATE_XRUN) {
|
||
|
#ifdef HAVE_GETTIMEOFDAY
|
||
|
struct timeval now, diff, tstamp;
|
||
|
|
||
|
gettimeofday(&now, NULL);
|
||
|
snd_pcm_status_get_trigger_tstamp(status, &tstamp);
|
||
|
TIMERSUB(&now, &tstamp, &diff);
|
||
|
|
||
|
tc_log_warn(__FILE__, "overrun at least %.3f ms long",
|
||
|
diff.tv_sec * 1000 + diff.tv_usec / 1000.0);
|
||
|
#else /* ! HAVE_GETTIMEOFDAY */
|
||
|
tc_log_warn(__FILE__, "overrun");
|
||
|
#endif /* HAVE_GETTIMEOFDAY */
|
||
|
ALSA_PREPARE(handle);
|
||
|
} else if (state == SND_PCM_STATE_DRAINING) {
|
||
|
tc_log_warn(__FILE__, "capture stream format change? attempting recover...");
|
||
|
ALSA_PREPARE(handle);
|
||
|
} else { /* catch all */
|
||
|
tc_log_error(__FILE__, "read error, state = %s", snd_pcm_state_name(state));
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
|
||
|
#define RETURN_IF_ALSA_FAIL(RET, MSG) do { \
|
||
|
if ((RET) < 0) { \
|
||
|
tc_log_error(__FILE__, "%s (%s)", (MSG), snd_strerror((RET))); \
|
||
|
return TC_ERROR; \
|
||
|
} \
|
||
|
} while (0)
|
||
|
|
||
|
static int tc_alsa_source_open(TCALSASource *handle, const char *dev,
|
||
|
int rate, int precision, int channels)
|
||
|
{
|
||
|
int ret = 0, alsa_rate = rate;
|
||
|
snd_pcm_hw_params_t *hwparams = NULL;
|
||
|
|
||
|
TC_MODULE_SELF_CHECK(handle, "alsa_source_open");
|
||
|
|
||
|
/* some basic sanity checks */
|
||
|
if (!strcmp(dev, "/dev/null") || !strcmp(dev, "/dev/zero")) {
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
if (!dev || !strlen(dev)) {
|
||
|
tc_log_warn(__FILE__, "bad ALSA device");
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
if (precision != 8 && precision != 16) {
|
||
|
tc_log_warn(__FILE__, "bits/sample must be 8 or 16");
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
|
||
|
handle->rate = rate;
|
||
|
handle->channels = channels;
|
||
|
handle->precision = precision;
|
||
|
|
||
|
/* ok, time to rock */
|
||
|
snd_pcm_hw_params_alloca(&(hwparams));
|
||
|
if (hwparams == NULL) {
|
||
|
tc_log_warn(__FILE__, "cannot allocate ALSA HW parameters");
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
|
||
|
tc_log_info(__FILE__, "using PCM capture device: %s", dev);
|
||
|
ret = snd_pcm_open(&(handle->pcm), dev, SND_PCM_STREAM_CAPTURE, 0);
|
||
|
if (ret < 0) {
|
||
|
tc_log_warn(__FILE__, "error opening PCM device %s\n", dev);
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
|
||
|
ret = snd_pcm_hw_params_any(handle->pcm, hwparams);
|
||
|
RETURN_IF_ALSA_FAIL(ret, "cannot preconfigure PCM device");
|
||
|
|
||
|
ret = snd_pcm_hw_params_set_access(handle->pcm, hwparams,
|
||
|
SND_PCM_ACCESS_RW_INTERLEAVED);
|
||
|
RETURN_IF_ALSA_FAIL(ret, "cannot setup PCM access");
|
||
|
|
||
|
ret = snd_pcm_hw_params_set_format(handle->pcm, hwparams,
|
||
|
(precision == 16) ?SND_PCM_FORMAT_S16_LE
|
||
|
:SND_PCM_FORMAT_S8);
|
||
|
RETURN_IF_ALSA_FAIL(ret, "cannot setup PCM format");
|
||
|
|
||
|
ret = snd_pcm_hw_params_set_rate_near(handle->pcm, hwparams, &alsa_rate, 0);
|
||
|
RETURN_IF_ALSA_FAIL(ret, "cannot setup PCM rate");
|
||
|
|
||
|
if (rate != alsa_rate) {
|
||
|
tc_log_warn(__FILE__, "rate %d Hz unsupported by hardware, using %d Hz instead",
|
||
|
rate, alsa_rate);
|
||
|
}
|
||
|
|
||
|
ret = snd_pcm_hw_params_set_channels(handle->pcm, hwparams, channels);
|
||
|
RETURN_IF_ALSA_FAIL(ret, "cannot setup PCM channels");
|
||
|
|
||
|
ret = snd_pcm_hw_params(handle->pcm, hwparams);
|
||
|
RETURN_IF_ALSA_FAIL(ret, "cannot setup hardware parameters");
|
||
|
|
||
|
tc_log_info(__FILE__, "ALSA audio capture: "
|
||
|
"%i Hz, %i bps, %i channels",
|
||
|
alsa_rate, precision, channels);
|
||
|
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
/* frame size = sample size (bytes) * sample number (= channels number) */
|
||
|
#define ALSA_FRAME_SIZE(HANDLE) \
|
||
|
((HANDLE)->channels * (HANDLE)->precision / 8)
|
||
|
|
||
|
|
||
|
static int tc_alsa_source_grab(TCALSASource *handle, uint8_t *buf,
|
||
|
size_t bufsize, size_t *buflen)
|
||
|
{
|
||
|
snd_pcm_uframes_t frames = bufsize / ALSA_FRAME_SIZE(handle);
|
||
|
snd_pcm_sframes_t ret = 0;
|
||
|
|
||
|
TC_MODULE_SELF_CHECK(handle, "alsa_source_grab");
|
||
|
TC_MODULE_SELF_CHECK(buf, "alsa_source_grab");
|
||
|
|
||
|
ret = snd_pcm_readi(handle->pcm, buf, frames);
|
||
|
if (ret == -EAGAIN || (ret >= 0 && (snd_pcm_uframes_t)ret < frames)) {
|
||
|
/* this can really happen? */
|
||
|
snd_pcm_wait(handle->pcm, -1);
|
||
|
} else if (ret == -EPIPE) { /* xrun (overrun) */
|
||
|
return alsa_source_xrun(handle);
|
||
|
} else if (ret == -ESTRPIPE) { /* suspend */
|
||
|
tc_log_error(__FILE__, "stream suspended (unrecoverable, yet)");
|
||
|
return TC_ERROR;
|
||
|
} else if (ret < 0) {
|
||
|
tc_log_error(__FILE__, "ALSA read error: %s", snd_strerror(ret));
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
|
||
|
if (buflen != NULL) {
|
||
|
*buflen = (size_t)ret;
|
||
|
}
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
static int tc_alsa_source_close(TCALSASource *handle)
|
||
|
{
|
||
|
TC_MODULE_SELF_CHECK(handle, "alsa_source_close");
|
||
|
|
||
|
if (handle->pcm != NULL) {
|
||
|
snd_pcm_close(handle->pcm);
|
||
|
handle->pcm = NULL;
|
||
|
}
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
#undef RETURN_IF_ALSA_FAIL
|
||
|
|
||
|
|
||
|
/* ------------------------------------------------------------
|
||
|
* New-Style module interface
|
||
|
* ------------------------------------------------------------*/
|
||
|
|
||
|
typedef struct tcalsaprivatedata_ TCALSAPrivateData;
|
||
|
struct tcalsaprivatedata_ {
|
||
|
TCALSASource handle;
|
||
|
};
|
||
|
|
||
|
|
||
|
static int tc_alsa_init(TCModuleInstance *self, uint32_t features)
|
||
|
{
|
||
|
TCALSAPrivateData *priv = NULL;
|
||
|
|
||
|
TC_MODULE_SELF_CHECK(self, "init");
|
||
|
TC_MODULE_INIT_CHECK(self, MOD_FEATURES, features);
|
||
|
|
||
|
if (verbose) {
|
||
|
tc_log_info(MOD_NAME, "%s %s", MOD_VERSION, MOD_CAP);
|
||
|
}
|
||
|
priv = tc_zalloc(sizeof(TCALSAPrivateData));
|
||
|
if (priv == NULL) {
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
|
||
|
self->userdata = priv;
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
static int tc_alsa_fini(TCModuleInstance *self)
|
||
|
{
|
||
|
TC_MODULE_SELF_CHECK(self, "fini");
|
||
|
|
||
|
tc_free(self->userdata);
|
||
|
self->userdata = NULL;
|
||
|
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
static int tc_alsa_configure(TCModuleInstance *self,
|
||
|
const char *options, vob_t *vob)
|
||
|
{
|
||
|
TCALSAPrivateData *priv = NULL;
|
||
|
int ret = 0;
|
||
|
char device[1024];
|
||
|
|
||
|
TC_MODULE_SELF_CHECK(self, "configure");
|
||
|
|
||
|
priv = self->userdata;
|
||
|
|
||
|
strlcpy(device, "default", TC_BUF_MAX);
|
||
|
if (options != NULL) {
|
||
|
optstr_get(options, "device", "%1024s", device);
|
||
|
device[1024-1] = '\0';
|
||
|
/* yeah, this is pretty ugly -- FR */
|
||
|
}
|
||
|
|
||
|
/* it would be nice to have some more validation in here */
|
||
|
ret = tc_alsa_source_open(&(priv->handle), device,
|
||
|
vob->a_rate, vob->a_bits, vob->a_chan);
|
||
|
if (ret != 0) {
|
||
|
tc_log_error(MOD_NAME, "configure: failed to open ALSA device"
|
||
|
"'%s'", device);
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
static int tc_alsa_inspect(TCModuleInstance *self,
|
||
|
const char *param, const char **value)
|
||
|
{
|
||
|
TC_MODULE_SELF_CHECK(self, "inspect");
|
||
|
|
||
|
if (optstr_lookup(param, "help")) {
|
||
|
*value = tc_alsa_help;
|
||
|
}
|
||
|
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
static int tc_alsa_stop(TCModuleInstance *self)
|
||
|
{
|
||
|
TCALSAPrivateData *priv = NULL;
|
||
|
int ret = 0;
|
||
|
|
||
|
TC_MODULE_SELF_CHECK(self, "stop");
|
||
|
|
||
|
priv = self->userdata;
|
||
|
|
||
|
ret = tc_alsa_source_close(&(priv->handle));
|
||
|
if (ret != TC_OK) {
|
||
|
tc_log_error(MOD_NAME, "stop: failed to close ALSA device");
|
||
|
return TC_ERROR;
|
||
|
}
|
||
|
|
||
|
return TC_OK;
|
||
|
}
|
||
|
|
||
|
static int tc_alsa_demultiplex(TCModuleInstance *self,
|
||
|
vframe_list_t *vframe, aframe_list_t *aframe)
|
||
|
{
|
||
|
TCALSAPrivateData *priv = NULL;
|
||
|
int ret = TC_OK;
|
||
|
size_t len = 0;
|
||
|
|
||
|
TC_MODULE_SELF_CHECK(self, "demultiplex");
|
||
|
|
||
|
priv = self->userdata;
|
||
|
|
||
|
if (vframe != NULL) {
|
||
|
vframe->video_len = 0; /* no audio from here */
|
||
|
ret = TC_OK;
|
||
|
}
|
||
|
|
||
|
if (aframe != NULL) {
|
||
|
ret = tc_alsa_source_grab(&(priv->handle), aframe->audio_buf,
|
||
|
aframe->audio_size, &len);
|
||
|
aframe->audio_len = (size_t)len;
|
||
|
}
|
||
|
return ret;
|
||
|
}
|
||
|
|
||
|
/*************************************************************************/
|
||
|
|
||
|
static const TCCodecID tc_alsa_codecs_in[] = { TC_CODEC_ERROR };
|
||
|
|
||
|
/* a multiplexor is at the end of pipeline */
|
||
|
static const TCCodecID tc_alsa_codecs_out[] = {
|
||
|
TC_CODEC_PCM,
|
||
|
TC_CODEC_ERROR,
|
||
|
};
|
||
|
|
||
|
static const TCFormatID tc_alsa_formats_in[] = {
|
||
|
TC_FORMAT_ALSA,
|
||
|
TC_FORMAT_ERROR,
|
||
|
};
|
||
|
|
||
|
static const TCFormatID tc_alsa_formats_out[] = { TC_FORMAT_ERROR };
|
||
|
|
||
|
static const TCModuleInfo tc_alsa_info = {
|
||
|
.features = MOD_FEATURES,
|
||
|
.flags = MOD_FLAGS,
|
||
|
.name = MOD_NAME,
|
||
|
.version = MOD_VERSION,
|
||
|
.description = MOD_CAP,
|
||
|
.codecs_in = tc_alsa_codecs_in,
|
||
|
.codecs_out = tc_alsa_codecs_out,
|
||
|
.formats_in = tc_alsa_formats_in,
|
||
|
.formats_out = tc_alsa_formats_out
|
||
|
};
|
||
|
|
||
|
static const TCModuleClass tc_alsa_class = {
|
||
|
TC_MODULE_CLASS_HEAD(tc_alsa),
|
||
|
|
||
|
.init = tc_alsa_init,
|
||
|
.fini = tc_alsa_fini,
|
||
|
.configure = tc_alsa_configure,
|
||
|
.stop = tc_alsa_stop,
|
||
|
.inspect = tc_alsa_inspect,
|
||
|
|
||
|
.demultiplex = tc_alsa_demultiplex,
|
||
|
};
|
||
|
|
||
|
TC_MODULE_ENTRY_POINT(tc_alsa)
|
||
|
|
||
|
/*************************************************************************/
|
||
|
|
||
|
/* ------------------------------------------------------------
|
||
|
* Old-Style module interface
|
||
|
* ------------------------------------------------------------*/
|
||
|
|
||
|
static int verbose_flag = TC_QUIET;
|
||
|
static int capability_flag = TC_CAP_PCM;
|
||
|
|
||
|
#define MOD_PRE alsa
|
||
|
#define MOD_CODEC "(audio) pcm"
|
||
|
|
||
|
#include "import_def.h"
|
||
|
|
||
|
|
||
|
static TCALSASource handle = {
|
||
|
.pcm = NULL,
|
||
|
.rate = RATE,
|
||
|
.channels = CHANNELS,
|
||
|
.precision = BITS,
|
||
|
};
|
||
|
|
||
|
|
||
|
MOD_open
|
||
|
{
|
||
|
int ret = TC_ERROR;
|
||
|
char device[1024];
|
||
|
|
||
|
switch (param->flag) {
|
||
|
case TC_VIDEO:
|
||
|
tc_log_warn(MOD_NAME, "unsupported request (init video)");
|
||
|
break;
|
||
|
case TC_AUDIO:
|
||
|
if (verbose_flag & TC_DEBUG) {
|
||
|
tc_log_info(MOD_NAME, "ALSA audio grabbing");
|
||
|
}
|
||
|
|
||
|
strlcpy(device, "default", 1024);
|
||
|
if (vob->im_a_string != NULL) {
|
||
|
optstr_get(vob->im_a_string, "device", "%1024s", device);
|
||
|
device[1024-1] = '\0';
|
||
|
/* yeah, this too is pretty ugly -- FR */
|
||
|
}
|
||
|
|
||
|
ret = tc_alsa_source_open(&handle, device,
|
||
|
vob->a_rate, vob->a_bits, vob->a_chan);
|
||
|
break;
|
||
|
default:
|
||
|
tc_log_warn(MOD_NAME, "unsupported request (init)");
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
return ret;
|
||
|
}
|
||
|
|
||
|
|
||
|
MOD_decode
|
||
|
{
|
||
|
int ret = TC_ERROR;
|
||
|
|
||
|
switch (param->flag) {
|
||
|
case TC_VIDEO:
|
||
|
tc_log_warn(MOD_NAME, "unsupported request (decode video)");
|
||
|
break;
|
||
|
case TC_AUDIO:
|
||
|
ret = tc_alsa_source_grab(&handle, param->buffer,
|
||
|
param->size, NULL);
|
||
|
break;
|
||
|
default:
|
||
|
tc_log_warn(MOD_NAME, "unsupported request (decode)");
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
return ret;
|
||
|
}
|
||
|
|
||
|
|
||
|
MOD_close
|
||
|
{
|
||
|
int ret = TC_ERROR;
|
||
|
|
||
|
switch (param->flag) {
|
||
|
case TC_VIDEO:
|
||
|
tc_log_warn(MOD_NAME, "unsupported request (close video)");
|
||
|
break;
|
||
|
case TC_AUDIO:
|
||
|
ret = tc_alsa_source_close(&handle);
|
||
|
break;
|
||
|
default:
|
||
|
tc_log_warn(MOD_NAME, "unsupported request (close)");
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
return ret;
|
||
|
}
|
||
|
|
||
|
/*************************************************************************/
|
||
|
|
||
|
/*
|
||
|
* Local variables:
|
||
|
* c-file-style: "stroustrup"
|
||
|
* c-file-offsets: ((case-label . *) (statement-case-intro . *))
|
||
|
* indent-tabs-mode: nil
|
||
|
* End:
|
||
|
*
|
||
|
* vim: expandtab shiftwidth=4:
|
||
|
*/
|