ASoC: twl6040: Add ramp up/down volume for HS and HF
[deliverable/linux.git] / sound / soc / codecs / twl6040.c
index b252cf8f0ce1b990459a147a5a758841b32e04cb..2f68f5949a630fa3e42e7c441376e3e4524eaaf5 100644 (file)
 #include "twl6040.h"
 
 #define TWL6040_RATES          SNDRV_PCM_RATE_8000_96000
-#define TWL6040_FORMATS         (SNDRV_PCM_FMTBIT_S32_LE)
+#define TWL6040_FORMATS        (SNDRV_PCM_FMTBIT_S32_LE)
+
+#define TWL6040_OUTHS_0dB 0x00
+#define TWL6040_OUTHS_M30dB 0x0F
+#define TWL6040_OUTHF_0dB 0x03
+#define TWL6040_OUTHF_M52dB 0x1D
+
+#define TWL6040_RAMP_NONE      0
+#define TWL6040_RAMP_UP                1
+#define TWL6040_RAMP_DOWN      2
+
+#define TWL6040_HSL_VOL_MASK   0x0F
+#define TWL6040_HSL_VOL_SHIFT  0
+#define TWL6040_HSR_VOL_MASK   0xF0
+#define TWL6040_HSR_VOL_SHIFT  4
+#define TWL6040_HF_VOL_MASK    0x1F
+#define TWL6040_HF_VOL_SHIFT   0
+
+struct twl6040_output {
+       u16 active;
+       u16 left_vol;
+       u16 right_vol;
+       u16 left_step;
+       u16 right_step;
+       unsigned int step_delay;
+       u16 ramp;
+       u16 mute;
+       struct completion ramp_done;
+};
 
 struct twl6040_jack_data {
        struct snd_soc_jack *jack;
@@ -62,6 +90,12 @@ struct twl6040_data {
        struct workqueue_struct *workqueue;
        struct delayed_work delayed_work;
        struct mutex mutex;
+       struct twl6040_output headset;
+       struct twl6040_output handsfree;
+       struct workqueue_struct *hf_workqueue;
+       struct workqueue_struct *hs_workqueue;
+       struct delayed_work hs_delayed_work;
+       struct delayed_work hf_delayed_work;
 };
 
 /*
@@ -263,6 +297,305 @@ static void twl6040_init_vdd_regs(struct snd_soc_codec *codec)
        }
 }
 
+/*
+ * Ramp HS PGA volume to minimise pops at stream startup and shutdown.
+ */
+static inline int twl6040_hs_ramp_step(struct snd_soc_codec *codec,
+                       unsigned int left_step, unsigned int right_step)
+{
+
+       struct twl6040_data *priv = snd_soc_codec_get_drvdata(codec);
+       struct twl6040_output *headset = &priv->headset;
+       int left_complete = 0, right_complete = 0;
+       u8 reg, val;
+
+       /* left channel */
+       left_step = (left_step > 0xF) ? 0xF : left_step;
+       reg = twl6040_read_reg_cache(codec, TWL6040_REG_HSGAIN);
+       val = (~reg & TWL6040_HSL_VOL_MASK);
+
+       if (headset->ramp == TWL6040_RAMP_UP) {
+               /* ramp step up */
+               if (val < headset->left_vol) {
+                       val += left_step;
+                       reg &= ~TWL6040_HSL_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HSGAIN,
+                                       (reg | (~val & TWL6040_HSL_VOL_MASK)));
+               } else {
+                       left_complete = 1;
+               }
+       } else if (headset->ramp == TWL6040_RAMP_DOWN) {
+               /* ramp step down */
+               if (val > 0x0) {
+                       val -= left_step;
+                       reg &= ~TWL6040_HSL_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HSGAIN, reg |
+                                               (~val & TWL6040_HSL_VOL_MASK));
+               } else {
+                       left_complete = 1;
+               }
+       }
+
+       /* right channel */
+       right_step = (right_step > 0xF) ? 0xF : right_step;
+       reg = twl6040_read_reg_cache(codec, TWL6040_REG_HSGAIN);
+       val = (~reg & TWL6040_HSR_VOL_MASK) >> TWL6040_HSR_VOL_SHIFT;
+
+       if (headset->ramp == TWL6040_RAMP_UP) {
+               /* ramp step up */
+               if (val < headset->right_vol) {
+                       val += right_step;
+                       reg &= ~TWL6040_HSR_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HSGAIN,
+                               (reg | (~val << TWL6040_HSR_VOL_SHIFT)));
+               } else {
+                       right_complete = 1;
+               }
+       } else if (headset->ramp == TWL6040_RAMP_DOWN) {
+               /* ramp step down */
+               if (val > 0x0) {
+                       val -= right_step;
+                       reg &= ~TWL6040_HSR_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HSGAIN,
+                                        reg | (~val << TWL6040_HSR_VOL_SHIFT));
+               } else {
+                       right_complete = 1;
+               }
+       }
+
+       return left_complete & right_complete;
+}
+
+/*
+ * Ramp HF PGA volume to minimise pops at stream startup and shutdown.
+ */
+static inline int twl6040_hf_ramp_step(struct snd_soc_codec *codec,
+                       unsigned int left_step, unsigned int right_step)
+{
+       struct twl6040_data *priv = snd_soc_codec_get_drvdata(codec);
+       struct twl6040_output *handsfree = &priv->handsfree;
+       int left_complete = 0, right_complete = 0;
+       u16 reg, val;
+
+       /* left channel */
+       left_step = (left_step > 0x1D) ? 0x1D : left_step;
+       reg = twl6040_read_reg_cache(codec, TWL6040_REG_HFLGAIN);
+       reg = 0x1D - reg;
+       val = (reg & TWL6040_HF_VOL_MASK);
+       if (handsfree->ramp == TWL6040_RAMP_UP) {
+               /* ramp step up */
+               if (val < handsfree->left_vol) {
+                       val += left_step;
+                       reg &= ~TWL6040_HF_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HFLGAIN,
+                                               reg | (0x1D - val));
+               } else {
+                       left_complete = 1;
+               }
+       } else if (handsfree->ramp == TWL6040_RAMP_DOWN) {
+               /* ramp step down */
+               if (val > 0) {
+                       val -= left_step;
+                       reg &= ~TWL6040_HF_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HFLGAIN,
+                                               reg | (0x1D - val));
+               } else {
+                       left_complete = 1;
+               }
+       }
+
+       /* right channel */
+       right_step = (right_step > 0x1D) ? 0x1D : right_step;
+       reg = twl6040_read_reg_cache(codec, TWL6040_REG_HFRGAIN);
+       reg = 0x1D - reg;
+       val = (reg & TWL6040_HF_VOL_MASK);
+       if (handsfree->ramp == TWL6040_RAMP_UP) {
+               /* ramp step up */
+               if (val < handsfree->right_vol) {
+                       val += right_step;
+                       reg &= ~TWL6040_HF_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HFRGAIN,
+                                               reg | (0x1D - val));
+               } else {
+                       right_complete = 1;
+               }
+       } else if (handsfree->ramp == TWL6040_RAMP_DOWN) {
+               /* ramp step down */
+               if (val > 0) {
+                       val -= right_step;
+                       reg &= ~TWL6040_HF_VOL_MASK;
+                       twl6040_write(codec, TWL6040_REG_HFRGAIN,
+                                               reg | (0x1D - val));
+               }
+       }
+
+       return left_complete & right_complete;
+}
+
+/*
+ * This work ramps both output PGAs at stream start/stop time to
+ * minimise pop associated with DAPM power switching.
+ */
+static void twl6040_pga_hs_work(struct work_struct *work)
+{
+       struct twl6040_data *priv =
+               container_of(work, struct twl6040_data, hs_delayed_work.work);
+       struct snd_soc_codec *codec = priv->codec;
+       struct twl6040_output *headset = &priv->headset;
+       unsigned int delay = headset->step_delay;
+       int i, headset_complete;
+
+       /* do we need to ramp at all ? */
+       if (headset->ramp == TWL6040_RAMP_NONE)
+               return;
+
+       /* HS PGA volumes have 4 bits of resolution to ramp */
+       for (i = 0; i <= 16; i++) {
+               headset_complete = 1;
+               if (headset->ramp != TWL6040_RAMP_NONE)
+                       headset_complete = twl6040_hs_ramp_step(codec,
+                                                       headset->left_step,
+                                                       headset->right_step);
+
+               /* ramp finished ? */
+               if (headset_complete)
+                       break;
+
+               /*
+                * TODO: tune: delay is longer over 0dB
+                * as increases are larger.
+                */
+               if (i >= 8)
+                       schedule_timeout_interruptible(msecs_to_jiffies(delay +
+                                                       (delay >> 1)));
+               else
+                       schedule_timeout_interruptible(msecs_to_jiffies(delay));
+       }
+
+       if (headset->ramp == TWL6040_RAMP_DOWN) {
+               headset->active = 0;
+               complete(&headset->ramp_done);
+       } else {
+               headset->active = 1;
+       }
+       headset->ramp = TWL6040_RAMP_NONE;
+}
+
+static void twl6040_pga_hf_work(struct work_struct *work)
+{
+       struct twl6040_data *priv =
+               container_of(work, struct twl6040_data, hf_delayed_work.work);
+       struct snd_soc_codec *codec = priv->codec;
+       struct twl6040_output *handsfree = &priv->handsfree;
+       unsigned int delay = handsfree->step_delay;
+       int i, handsfree_complete;
+
+       /* do we need to ramp at all ? */
+       if (handsfree->ramp == TWL6040_RAMP_NONE)
+               return;
+
+       /* HF PGA volumes have 5 bits of resolution to ramp */
+       for (i = 0; i <= 32; i++) {
+               handsfree_complete = 1;
+               if (handsfree->ramp != TWL6040_RAMP_NONE)
+                       handsfree_complete = twl6040_hf_ramp_step(codec,
+                                                       handsfree->left_step,
+                                                       handsfree->right_step);
+
+               /* ramp finished ? */
+               if (handsfree_complete)
+                       break;
+
+               /*
+                * TODO: tune: delay is longer over 0dB
+                * as increases are larger.
+                */
+               if (i >= 16)
+                       schedule_timeout_interruptible(msecs_to_jiffies(delay +
+                                                      (delay >> 1)));
+               else
+                       schedule_timeout_interruptible(msecs_to_jiffies(delay));
+       }
+
+
+       if (handsfree->ramp == TWL6040_RAMP_DOWN) {
+               handsfree->active = 0;
+               complete(&handsfree->ramp_done);
+       } else
+               handsfree->active = 1;
+       handsfree->ramp = TWL6040_RAMP_NONE;
+}
+
+static int pga_event(struct snd_soc_dapm_widget *w,
+                       struct snd_kcontrol *kcontrol, int event)
+{
+       struct snd_soc_codec *codec = w->codec;
+       struct twl6040_data *priv = snd_soc_codec_get_drvdata(codec);
+       struct twl6040_output *out;
+       struct delayed_work *work;
+       struct workqueue_struct *queue;
+
+       switch (w->shift) {
+       case 2:
+       case 3:
+               out = &priv->headset;
+               work = &priv->hs_delayed_work;
+               queue = priv->hs_workqueue;
+               out->step_delay = 5;    /* 5 ms between volume ramp steps */
+               break;
+       case 4:
+               out = &priv->handsfree;
+               work = &priv->hf_delayed_work;
+               queue = priv->hf_workqueue;
+               out->step_delay = 5;    /* 5 ms between volume ramp steps */
+               if (SND_SOC_DAPM_EVENT_ON(event))
+                       priv->non_lp++;
+               else
+                       priv->non_lp--;
+               break;
+       default:
+               return -1;
+       }
+
+       switch (event) {
+       case SND_SOC_DAPM_POST_PMU:
+               if (out->active)
+                       break;
+
+               /* don't use volume ramp for power-up */
+               out->left_step = out->left_vol;
+               out->right_step = out->right_vol;
+
+               if (!delayed_work_pending(work)) {
+                       out->ramp = TWL6040_RAMP_UP;
+                       queue_delayed_work(queue, work,
+                                       msecs_to_jiffies(1));
+               }
+               break;
+
+       case SND_SOC_DAPM_PRE_PMD:
+               if (!out->active)
+                       break;
+
+               if (!delayed_work_pending(work)) {
+                       /* use volume ramp for power-down */
+                       out->left_step = 1;
+                       out->right_step = 1;
+                       out->ramp = TWL6040_RAMP_DOWN;
+                       INIT_COMPLETION(out->ramp_done);
+
+                       queue_delayed_work(queue, work,
+                                       msecs_to_jiffies(1));
+
+                       wait_for_completion_timeout(&out->ramp_done,
+                                       msecs_to_jiffies(2000));
+               }
+               break;
+       }
+
+       return 0;
+}
+
 /* twl6040 codec manual power-up sequence */
 static void twl6040_power_up(struct snd_soc_codec *codec)
 {
@@ -463,6 +796,156 @@ static irqreturn_t twl6040_naudint_handler(int irq, void *data)
        return IRQ_HANDLED;
 }
 
+static int twl6040_put_volsw(struct snd_kcontrol *kcontrol,
+                                 struct snd_ctl_elem_value *ucontrol)
+{
+       struct snd_soc_codec *codec = snd_kcontrol_chip(kcontrol);
+       struct twl6040_data *twl6040_priv = snd_soc_codec_get_drvdata(codec);
+       struct twl6040_output *out = NULL;
+       struct soc_mixer_control *mc =
+               (struct soc_mixer_control *)kcontrol->private_value;
+       int ret;
+       unsigned int reg = mc->reg;
+
+       /* For HS and HF we shadow the values and only actually write
+        * them out when active in order to ensure the amplifier comes on
+        * as quietly as possible. */
+       switch (reg) {
+       case TWL6040_REG_HSGAIN:
+               out = &twl6040_priv->headset;
+               break;
+       default:
+               break;
+       }
+
+       if (out) {
+               out->left_vol = ucontrol->value.integer.value[0];
+               out->right_vol = ucontrol->value.integer.value[1];
+               if (!out->active)
+                       return 1;
+       }
+
+       ret = snd_soc_put_volsw(kcontrol, ucontrol);
+       if (ret < 0)
+               return ret;
+
+       return 1;
+}
+
+static int twl6040_get_volsw(struct snd_kcontrol *kcontrol,
+                               struct snd_ctl_elem_value *ucontrol)
+{
+       struct snd_soc_codec *codec = snd_kcontrol_chip(kcontrol);
+       struct twl6040_data *twl6040_priv = snd_soc_codec_get_drvdata(codec);
+       struct twl6040_output *out = &twl6040_priv->headset;
+       struct soc_mixer_control *mc =
+               (struct soc_mixer_control *)kcontrol->private_value;
+       unsigned int reg = mc->reg;
+
+       switch (reg) {
+       case TWL6040_REG_HSGAIN:
+               out = &twl6040_priv->headset;
+               ucontrol->value.integer.value[0] = out->left_vol;
+               ucontrol->value.integer.value[1] = out->right_vol;
+               return 0;
+
+       default:
+               break;
+       }
+
+       return snd_soc_get_volsw(kcontrol, ucontrol);
+}
+
+static int twl6040_put_volsw_2r_vu(struct snd_kcontrol *kcontrol,
+                                 struct snd_ctl_elem_value *ucontrol)
+{
+       struct snd_soc_codec *codec = snd_kcontrol_chip(kcontrol);
+       struct twl6040_data *twl6040_priv = snd_soc_codec_get_drvdata(codec);
+       struct twl6040_output *out = NULL;
+       struct soc_mixer_control *mc =
+               (struct soc_mixer_control *)kcontrol->private_value;
+       int ret;
+       unsigned int reg = mc->reg;
+
+       /* For HS and HF we shadow the values and only actually write
+        * them out when active in order to ensure the amplifier comes on
+        * as quietly as possible. */
+       switch (reg) {
+       case TWL6040_REG_HFLGAIN:
+       case TWL6040_REG_HFRGAIN:
+               out = &twl6040_priv->handsfree;
+               break;
+       default:
+               break;
+       }
+
+       if (out) {
+               out->left_vol = ucontrol->value.integer.value[0];
+               out->right_vol = ucontrol->value.integer.value[1];
+               if (!out->active)
+                       return 1;
+       }
+
+       ret = snd_soc_put_volsw_2r(kcontrol, ucontrol);
+       if (ret < 0)
+               return ret;
+
+       return 1;
+}
+
+static int twl6040_get_volsw_2r(struct snd_kcontrol *kcontrol,
+                               struct snd_ctl_elem_value *ucontrol)
+{
+       struct snd_soc_codec *codec = snd_kcontrol_chip(kcontrol);
+       struct twl6040_data *twl6040_priv = snd_soc_codec_get_drvdata(codec);
+       struct twl6040_output *out = &twl6040_priv->handsfree;
+       struct soc_mixer_control *mc =
+               (struct soc_mixer_control *)kcontrol->private_value;
+       unsigned int reg = mc->reg;
+
+       /* If these are cached registers use the cache */
+       switch (reg) {
+       case TWL6040_REG_HFLGAIN:
+       case TWL6040_REG_HFRGAIN:
+               out = &twl6040_priv->handsfree;
+               ucontrol->value.integer.value[0] = out->left_vol;
+               ucontrol->value.integer.value[1] = out->right_vol;
+               return 0;
+
+       default:
+               break;
+       }
+
+       return snd_soc_get_volsw_2r(kcontrol, ucontrol);
+}
+
+/* double control with volume update */
+#define SOC_TWL6040_DOUBLE_TLV(xname, xreg, shift_left, shift_right, xmax,\
+                                                       xinvert, tlv_array)\
+{      .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = (xname),\
+       .access = SNDRV_CTL_ELEM_ACCESS_TLV_READ |\
+                SNDRV_CTL_ELEM_ACCESS_READWRITE,\
+       .tlv.p = (tlv_array), \
+       .info = snd_soc_info_volsw, .get = twl6040_get_volsw, \
+       .put = twl6040_put_volsw, \
+       .private_value = (unsigned long)&(struct soc_mixer_control) \
+               {.reg = xreg, .shift = shift_left, .rshift = shift_right,\
+                .max = xmax, .platform_max = xmax, .invert = xinvert} }
+
+/* double control with volume update */
+#define SOC_TWL6040_DOUBLE_R_TLV(xname, reg_left, reg_right, xshift, xmax,\
+                               xinvert, tlv_array)\
+{      .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = (xname),\
+       .access = SNDRV_CTL_ELEM_ACCESS_TLV_READ | \
+               SNDRV_CTL_ELEM_ACCESS_READWRITE | \
+               SNDRV_CTL_ELEM_ACCESS_VOLATILE, \
+       .tlv.p = (tlv_array), \
+       .info = snd_soc_info_volsw_2r, \
+       .get = twl6040_get_volsw_2r, .put = twl6040_put_volsw_2r_vu, \
+       .private_value = (unsigned long)&(struct soc_mixer_control) \
+               {.reg = reg_left, .rreg = reg_right, .shift = xshift, \
+                .rshift = xshift, .max = xmax, .invert = xinvert}, }
+
 /*
  * MICATT volume control:
  * from -6 to 0 dB in 6 dB steps
@@ -569,9 +1052,9 @@ static const struct snd_kcontrol_new twl6040_snd_controls[] = {
                TWL6040_REG_LINEGAIN, 0, 4, 0xF, 0, afm_amp_tlv),
 
        /* Playback gains */
-       SOC_DOUBLE_TLV("Headset Playback Volume",
+       SOC_TWL6040_DOUBLE_TLV("Headset Playback Volume",
                TWL6040_REG_HSGAIN, 0, 4, 0xF, 1, hs_tlv),
-       SOC_DOUBLE_R_TLV("Handsfree Playback Volume",
+       SOC_TWL6040_DOUBLE_R_TLV("Handsfree Playback Volume",
                TWL6040_REG_HFLGAIN, TWL6040_REG_HFRGAIN, 0, 0x1D, 1, hf_tlv),
        SOC_SINGLE_TLV("Earphone Playback Volume",
                TWL6040_REG_EARCTL, 1, 0xF, 1, ep_tlv),
@@ -657,16 +1140,20 @@ static const struct snd_soc_dapm_widget twl6040_dapm_widgets[] = {
        /* Analog playback drivers */
        SND_SOC_DAPM_PGA_E("Handsfree Left Driver",
                        TWL6040_REG_HFLCTL, 4, 0, NULL, 0,
-                       twl6040_power_mode_event,
-                       SND_SOC_DAPM_POST_PMU | SND_SOC_DAPM_POST_PMD),
+                       pga_event,
+                       SND_SOC_DAPM_POST_PMU | SND_SOC_DAPM_PRE_PMD),
        SND_SOC_DAPM_PGA_E("Handsfree Right Driver",
                        TWL6040_REG_HFRCTL, 4, 0, NULL, 0,
-                       twl6040_power_mode_event,
-                       SND_SOC_DAPM_POST_PMU | SND_SOC_DAPM_POST_PMD),
-       SND_SOC_DAPM_PGA("Headset Left Driver",
-                       TWL6040_REG_HSLCTL, 2, 0, NULL, 0),
-       SND_SOC_DAPM_PGA("Headset Right Driver",
-                       TWL6040_REG_HSRCTL, 2, 0, NULL, 0),
+                       pga_event,
+                       SND_SOC_DAPM_POST_PMU | SND_SOC_DAPM_PRE_PMD),
+       SND_SOC_DAPM_PGA_E("Headset Left Driver",
+                       TWL6040_REG_HSLCTL, 2, 0, NULL, 0,
+                       pga_event,
+                       SND_SOC_DAPM_POST_PMU | SND_SOC_DAPM_PRE_PMD),
+       SND_SOC_DAPM_PGA_E("Headset Right Driver",
+                       TWL6040_REG_HSRCTL, 2, 0, NULL, 0,
+                       pga_event,
+                       SND_SOC_DAPM_POST_PMU | SND_SOC_DAPM_PRE_PMD),
        SND_SOC_DAPM_SWITCH_E("Earphone Driver",
                        SND_SOC_NOPM, 0, 0, &ep_driver_switch_controls,
                        twl6040_power_mode_event,
@@ -1150,6 +1637,8 @@ static int twl6040_probe(struct snd_soc_codec *codec)
        mutex_init(&priv->mutex);
 
        init_completion(&priv->ready);
+       init_completion(&priv->headset.ramp_done);
+       init_completion(&priv->handsfree.ramp_done);
 
        if (gpio_is_valid(audpwron)) {
                ret = gpio_request(audpwron, "audpwron");
@@ -1183,10 +1672,24 @@ static int twl6040_probe(struct snd_soc_codec *codec)
        /* init vio registers */
        twl6040_init_vio_regs(codec);
 
+       priv->hf_workqueue = create_singlethread_workqueue("twl6040-hf");
+       if (priv->hf_workqueue == NULL) {
+               ret = -ENOMEM;
+               goto irq_err;
+       }
+       priv->hs_workqueue = create_singlethread_workqueue("twl6040-hs");
+       if (priv->hs_workqueue == NULL) {
+               ret = -ENOMEM;
+               goto wq_err;
+       }
+
+       INIT_DELAYED_WORK(&priv->hs_delayed_work, twl6040_pga_hs_work);
+       INIT_DELAYED_WORK(&priv->hf_delayed_work, twl6040_pga_hf_work);
+
        /* power on device */
        ret = twl6040_set_bias_level(codec, SND_SOC_BIAS_STANDBY);
        if (ret)
-               goto irq_err;
+               goto bias_err;
 
        snd_soc_add_controls(codec, twl6040_snd_controls,
                                ARRAY_SIZE(twl6040_snd_controls));
@@ -1194,6 +1697,10 @@ static int twl6040_probe(struct snd_soc_codec *codec)
 
        return 0;
 
+bias_err:
+       destroy_workqueue(priv->hs_workqueue);
+wq_err:
+       destroy_workqueue(priv->hf_workqueue);
 irq_err:
        if (naudint)
                free_irq(naudint, codec);
@@ -1222,6 +1729,8 @@ static int twl6040_remove(struct snd_soc_codec *codec)
                free_irq(naudint, codec);
 
        destroy_workqueue(priv->workqueue);
+       destroy_workqueue(priv->hf_workqueue);
+       destroy_workqueue(priv->hs_workqueue);
        kfree(priv);
 
        return 0;
This page took 0.055688 seconds and 5 git commands to generate.